fix(ui): drop setState-in-effect in useAuthSessionExpiry
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s

CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 20:15:11 -04:00
parent 8d79dd93b8
commit cbb4b25671
34 changed files with 8 additions and 5 deletions

View File

@@ -0,0 +1,76 @@
# Survey Invite Tracking — Design
> **Date:** 2026-03-04
> **Status:** Approved
## Goal
Add invite tracking to the FlowPilot survey so Michael can create personalized links, optionally email them, and see who has/hasn't responded. Each invite token is single-use — one submission per token.
## Data Model
### New table: `survey_invites`
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID PK | |
| `token` | VARCHAR(32) UNIQUE | Random URL-safe token |
| `recipient_name` | VARCHAR(255) NOT NULL | Who it's for |
| `recipient_email` | VARCHAR(255) NULL | Only if emailing |
| `status` | VARCHAR(20) DEFAULT 'pending' | `pending` or `completed` |
| `email_sent` | BOOLEAN DEFAULT false | Whether Resend email was sent |
| `created_at` | TIMESTAMPTZ NOT NULL | |
| `completed_at` | TIMESTAMPTZ NULL | Set on submission |
### Modified table: `survey_responses`
Add `invite_id` UUID FK nullable → `survey_invites.id`. Responses from tokenless `/survey` have `invite_id = NULL`.
## API Endpoints
### Public (no auth)
- `GET /api/v1/survey/invite/{token}` — Returns invite status (`{ name, status }`). If `completed`, frontend shows "already submitted" screen. Returns 404 for invalid tokens.
- `POST /api/v1/survey/submit` — Modified: accepts optional `token` field. If token provided, validates it's `pending`, links the response, and marks invite as `completed`. Returns 409 if token already used.
### Admin (super_admin auth)
- `POST /api/v1/admin/survey-invites` — Create invite. Body: `{ recipient_name, recipient_email?, send_email? }`. Generates token, optionally sends email. Returns the invite with the full survey URL.
- `GET /api/v1/admin/survey-invites` — List all invites with status.
## Frontend
### Survey page changes (`/survey`)
- On load, reads `?t=<token>` from URL params
- If token present: calls `GET /survey/invite/{token}`
- If `completed` → show "already submitted" screen
- If `pending` → show survey, include token in submission payload
- If 404 → show survey without token (treat as open link)
- If no token: show survey as-is (open access)
### Admin page (`/admin/survey-invites`)
**Top section: Create Invite**
- Name input (required) + Email input (optional)
- "Generate Link" button → creates invite, shows URL with copy button
- "Send Email" button → creates invite with `send_email: true`, shows confirmation toast
- "Send Email" only enabled when email field is filled
**Bottom section: Invite Table**
- Columns: Name, Email, Status badge (pending amber / completed green), Sent (email icon or dash), Created date, Completed date
- Sorted by created_at descending
## Email Template
Uses existing `EmailService` + Resend pattern. Dark-themed email matching Slate & Ice aesthetic:
- Subject: "FlowPilot Survey — Your expertise matters"
- Body: Brief intro, CTA button linking to `/survey?t=<token>`, ~5 minutes note
- From: existing `FROM_EMAIL` config
## Constraints
- No token expiration
- No reminder/resend (keep it simple)
- Tokenless survey still works for open sharing
- One submission per invite token (enforced backend + frontend)

View File

@@ -0,0 +1,819 @@
# Survey Invite Tracking — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add invite tracking to the FlowPilot survey so the admin can create personalized single-use links, optionally email them, and see who has/hasn't responded.
**Architecture:** New `survey_invites` table with token-based tracking. `survey_responses` gets an `invite_id` FK. Public endpoint checks token status; submit endpoint validates and marks tokens as used. Admin CRUD endpoints behind `require_admin`. Frontend: admin page for creating/viewing invites, survey page reads `?t=<token>` and gates on completion.
**Tech Stack:** FastAPI backend, SQLAlchemy 2.0 (async), Alembic, Pydantic v2. React 19, TypeScript, Tailwind CSS frontend. Existing `EmailService` + Resend for invite emails.
**Design doc:** `docs/plans/2026-03-04-survey-invite-tracking-design.md`
---
## Phase 1: Backend
### Task 1: SurveyInvite Model
**Files:**
- Create: `backend/app/models/survey_invite.py`
- Modify: `backend/app/models/__init__.py`
- Modify: `backend/alembic/env.py`
**Step 1: Create the model file**
```python
# backend/app/models/survey_invite.py
"""Survey invite tracking for FlowPilot research."""
import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
def _generate_token() -> str:
return secrets.token_urlsafe(16)
class SurveyInvite(Base):
__tablename__ = "survey_invites"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
token = Column(String(32), unique=True, nullable=False, default=_generate_token)
recipient_name = Column(String(255), nullable=False)
recipient_email = Column(String(255), nullable=True)
status = Column(String(20), nullable=False, default="pending")
email_sent = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
```
**Step 2: Add to models `__init__.py`**
Add import and `__all__` entry after `SurveyResponse`:
```python
from .survey_invite import SurveyInvite
```
And in `__all__`:
```python
"SurveyInvite",
```
**Step 3: Add import to `backend/alembic/env.py`**
After the `SurveyResponse` import line:
```python
from app.models.survey_invite import SurveyInvite
```
**Step 4: Commit**
```bash
git add backend/app/models/survey_invite.py backend/app/models/__init__.py backend/alembic/env.py
git commit -m "feat: add SurveyInvite model"
```
---
### Task 2: Migration — survey_invites table + invite_id FK on survey_responses
**Files:**
- Create: `backend/alembic/versions/047_add_survey_invites_table.py`
**Step 1: Create migration manually**
```python
"""Add survey_invites table and invite_id FK on survey_responses.
Revision ID: 047
Revises: 046
Create Date: 2026-03-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision: str = "047"
down_revision: str = "046"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"survey_invites",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("token", sa.String(32), unique=True, nullable=False),
sa.Column("recipient_name", sa.String(255), nullable=False),
sa.Column("recipient_email", sa.String(255), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("email_sent", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"survey_responses",
sa.Column("invite_id", UUID(as_uuid=True), sa.ForeignKey("survey_invites.id"), nullable=True),
)
def downgrade() -> None:
op.drop_column("survey_responses", "invite_id")
op.drop_table("survey_invites")
```
**Step 2: Add `invite_id` column to SurveyResponse model**
In `backend/app/models/survey_response.py`, add after `user_agent`:
```python
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
# ... existing columns ...
invite_id = Column(UUID(as_uuid=True), ForeignKey("survey_invites.id"), nullable=True)
```
Note: update the `ForeignKey` import on the existing import line.
**Step 3: Commit**
```bash
git add backend/alembic/versions/047_add_survey_invites_table.py backend/app/models/survey_response.py
git commit -m "feat: add survey_invites migration and invite_id FK"
```
---
### Task 3: Pydantic Schemas for Invites
**Files:**
- Modify: `backend/app/schemas/survey.py`
**Step 1: Add invite schemas and update SurveySubmission**
Append to `backend/app/schemas/survey.py`:
```python
from datetime import datetime
class SurveySubmission(BaseModel):
"""Incoming survey response from the public form."""
respondent_name: Optional[str] = Field(None, max_length=255)
responses: dict[str, Any] = Field(
...,
description="Question ID -> answer mapping. Values can be strings, lists, or numbers.",
)
token: Optional[str] = Field(None, description="Invite token for tracking")
class SurveyInviteCreate(BaseModel):
"""Create a new survey invite."""
recipient_name: str = Field(..., min_length=1, max_length=255)
recipient_email: Optional[str] = Field(None, max_length=255)
send_email: bool = False
class SurveyInviteResponse(BaseModel):
"""Invite details returned to admin."""
id: str
token: str
recipient_name: str
recipient_email: Optional[str]
status: str
email_sent: bool
created_at: datetime
completed_at: Optional[datetime]
survey_url: str
class SurveyInviteStatus(BaseModel):
"""Public invite status check — minimal info."""
name: str
status: str
```
Note: Also add the `token` field to the existing `SurveySubmission` class (replace it entirely in the file).
**Step 2: Commit**
```bash
git add backend/app/schemas/survey.py
git commit -m "feat: add survey invite schemas and token field"
```
---
### Task 4: Public Invite Status Endpoint + Update Submit Endpoint
**Files:**
- Modify: `backend/app/api/endpoints/survey.py`
**Step 1: Add invite status check endpoint**
Add above the existing `submit_survey` function:
```python
from fastapi import HTTPException
from sqlalchemy import select
from datetime import datetime, timezone
from app.models.survey_invite import SurveyInvite
from app.schemas.survey import SurveyInviteStatus
@router.get("/survey/invite/{token}", response_model=SurveyInviteStatus)
async def check_invite_status(
token: str,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Check if a survey invite token is valid and its status."""
result = await db.execute(
select(SurveyInvite).where(SurveyInvite.token == token)
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="Invalid invite token")
return SurveyInviteStatus(name=invite.recipient_name, status=invite.status)
```
**Step 2: Update submit_survey to handle tokens**
Modify `submit_survey` to:
1. If `data.token` is provided, look up the invite
2. If invite exists and is `completed`, return 409
3. If invite exists and is `pending`, link the response and mark invite completed
4. If token not found, ignore (treat as open submission)
Replace the body of `submit_survey` with:
```python
ip = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
invite = None
if data.token:
result = await db.execute(
select(SurveyInvite).where(SurveyInvite.token == data.token)
)
invite = result.scalar_one_or_none()
if invite and invite.status == "completed":
raise HTTPException(status_code=409, detail="This survey has already been submitted")
response = SurveyResponse(
respondent_name=data.respondent_name or (invite.recipient_name if invite else None),
responses=data.responses,
ip_address=ip,
user_agent=ua,
invite_id=invite.id if invite else None,
)
db.add(response)
if invite:
invite.status = "completed"
invite.completed_at = datetime.now(timezone.utc)
await db.flush()
try:
if settings.FEEDBACK_EMAIL:
await EmailService.send_survey_notification_email(
to_email=settings.FEEDBACK_EMAIL,
respondent_name=response.respondent_name,
responses=data.responses,
)
except Exception:
logger.exception("Failed to send survey notification email")
await db.commit()
return SurveySubmissionResponse(id=str(response.id))
```
**Step 3: Commit**
```bash
git add backend/app/api/endpoints/survey.py
git commit -m "feat: add invite status check and token validation on submit"
```
---
### Task 5: Admin Invite Endpoints
**Files:**
- Create: `backend/app/api/endpoints/admin_survey.py`
- Modify: `backend/app/api/router.py`
**Step 1: Create admin survey endpoints**
```python
# backend/app/api/endpoints/admin_survey.py
"""Admin endpoints for managing survey invites."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.config import settings
from app.core.database import get_db
from app.core.email import EmailService
from app.models.survey_invite import SurveyInvite
from app.models.user import User
from app.schemas.survey import SurveyInviteCreate, SurveyInviteResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin-survey"])
FRONTEND_URL = "https://resolutionflow.com"
def _build_invite_response(invite: SurveyInvite) -> SurveyInviteResponse:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
return SurveyInviteResponse(
id=str(invite.id),
token=invite.token,
recipient_name=invite.recipient_name,
recipient_email=invite.recipient_email,
status=invite.status,
email_sent=invite.email_sent,
created_at=invite.created_at,
completed_at=invite.completed_at,
survey_url=f"{base_url}/survey?t={invite.token}",
)
@router.post("/survey-invites", response_model=SurveyInviteResponse)
async def create_survey_invite(
data: SurveyInviteCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a survey invite. Optionally sends email."""
invite = SurveyInvite(
recipient_name=data.recipient_name,
recipient_email=data.recipient_email,
)
db.add(invite)
await db.flush()
if data.send_email and data.recipient_email:
try:
base_url = FRONTEND_URL if not settings.DEBUG else "http://localhost:5173"
survey_url = f"{base_url}/survey?t={invite.token}"
sent = await EmailService.send_survey_invite_email(
to_email=data.recipient_email,
recipient_name=data.recipient_name,
survey_url=survey_url,
)
if sent:
invite.email_sent = True
except Exception:
logger.exception("Failed to send survey invite email")
await db.commit()
await db.refresh(invite)
return _build_invite_response(invite)
@router.get("/survey-invites", response_model=list[SurveyInviteResponse])
async def list_survey_invites(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey invites."""
result = await db.execute(
select(SurveyInvite).order_by(SurveyInvite.created_at.desc())
)
invites = result.scalars().all()
return [_build_invite_response(i) for i in invites]
```
**Step 2: Register in router.py**
Add to `backend/app/api/router.py`:
```python
from app.api.endpoints import admin_survey
```
And at the end:
```python
api_router.include_router(admin_survey.router)
```
**Step 3: Commit**
```bash
git add backend/app/api/endpoints/admin_survey.py backend/app/api/router.py
git commit -m "feat: add admin survey invite CRUD endpoints"
```
---
### Task 6: Survey Invite Email
**Files:**
- Modify: `backend/app/core/email.py`
**Step 1: Add `send_survey_invite_email` to EmailService**
Add a new static method to `EmailService` (before the `_render_invite_html` helper functions). Follow the existing dark-themed email pattern using the Slate & Ice color scheme (`#101114` bg, `#06b6d4` accent, `#f8fafc` text):
```python
@staticmethod
async def send_survey_invite_email(
to_email: str,
recipient_name: str,
survey_url: str,
) -> bool:
"""Send survey invite email."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
resend.api_key = settings.RESEND_API_KEY
safe_name = html_mod.escape(recipient_name)
subject = "FlowPilot Survey — Your expertise matters"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">FlowPilot Research</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name},
</p>
<p style="margin:12px 0 0;color:#8891a0;font-size:16px;line-height:1.6;">
We're building an AI assistant for MSP engineers and would love your input. This 5-minute survey will help shape how FlowPilot thinks about troubleshooting.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{survey_url}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#22d3ee);color:#101114;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:10px;">
Take the Survey
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Your responses are confidential. Takes about 5 minutes.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey invite email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send survey invite email to %s", to_email)
return False
```
**Step 2: Commit**
```bash
git add backend/app/core/email.py
git commit -m "feat: add survey invite email template"
```
---
## Phase 2: Frontend
### Task 7: Update SurveyPage to Handle Invite Tokens
**Files:**
- Modify: `frontend/src/pages/SurveyPage.tsx`
**Step 1: Add token handling**
At the top of the component, add URL param reading and invite status check. Add these state variables and an effect:
```typescript
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
// Inside SurveyPage component, at the top:
const [searchParams] = useSearchParams()
const token = searchParams.get('t')
const [inviteName, setInviteName] = useState<string | null>(null)
const [alreadyCompleted, setAlreadyCompleted] = useState(false)
const [tokenLoading, setTokenLoading] = useState(!!token)
useEffect(() => {
if (!token) return
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
fetch(`${apiUrl}/api/v1/survey/invite/${token}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.status === 'completed') setAlreadyCompleted(true)
if (data?.name) setInviteName(data.name)
})
.catch(() => {})
.finally(() => setTokenLoading(false))
}, [token])
```
**Step 2: Add the "already completed" screen**
After the `tokenLoading` early return, before the main render, add:
```typescript
if (tokenLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
)
}
if (alreadyCompleted) {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Same atmosphere orbs as main page */}
<div className="relative z-10 mx-auto max-w-[680px] px-5">
<div className="text-center pt-32 animate-fade-in-up">
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(6, 182, 212, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
</p>
</div>
</div>
</div>
)
}
```
**Step 3: Include token in submission**
In the `handleSubmit` function, update the fetch body to include the token:
```typescript
body: JSON.stringify({ respondent_name: inviteName, responses: answers, token }),
```
**Step 4: Handle 409 response**
In the `handleSubmit` catch block, check for 409:
```typescript
if (!res.ok) {
if (res.status === 409) {
setAlreadyCompleted(true)
return
}
const data = await res.json().catch(() => null)
throw new Error(data?.detail || `Submission failed (${res.status})`)
}
```
**Step 5: Verify build**
```bash
cd frontend && npm run build
```
**Step 6: Commit**
```bash
git add frontend/src/pages/SurveyPage.tsx
git commit -m "feat: add invite token handling to survey page"
```
---
### Task 8: Admin Survey Invites Page
**Files:**
- Create: `frontend/src/pages/admin/SurveyInvitesPage.tsx`
- Modify: `frontend/src/api/admin.ts`
- Modify: `frontend/src/components/admin/AdminSidebar.tsx`
- Modify: `frontend/src/router.tsx`
**Step 1: Add API methods to `frontend/src/api/admin.ts`**
Before the closing `}` of `adminApi`, add:
```typescript
// Survey Invites
listSurveyInvites: () =>
api.get<SurveyInviteResponse[]>('/admin/survey-invites').then(r => r.data),
createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) =>
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
```
Add the `SurveyInviteResponse` type to the imports at the top of `admin.ts` (you'll define it in the types file, or inline it). Simplest: add inline interface at the top of the file:
```typescript
export interface SurveyInviteResponse {
id: string
token: string
recipient_name: string
recipient_email: string | null
status: string
email_sent: boolean
created_at: string
completed_at: string | null
survey_url: string
}
```
**Step 2: Create the admin page**
Create `frontend/src/pages/admin/SurveyInvitesPage.tsx`. This page has:
- **Create section** at top: name input + email input + two buttons ("Generate Link" / "Send Email")
- **Generated link display** with copy button (shown after creating)
- **Table** below showing all invites
Use the existing admin component patterns (`PageHeader`, `DataTable`, `StatusBadge`) and the Slate & Ice design system (glass cards, cyan accents). Follow the same patterns as `InviteCodesPage.tsx`.
**Step 3: Add nav item to AdminSidebar**
In `frontend/src/components/admin/AdminSidebar.tsx`, add to `navItems` array after the Categories entry:
```typescript
import { ClipboardList } from 'lucide-react'
// In navItems array:
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
```
**Step 4: Add route to `frontend/src/router.tsx`**
Add lazy import:
```typescript
const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage'))
```
Add route as child of the `admin` route, after `categories`:
```typescript
{
path: 'survey-invites',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyInvitesPage />
</Suspense>
),
},
```
**Step 5: Verify build**
```bash
cd frontend && npm run build
```
**Step 6: Commit**
```bash
git add frontend/src/pages/admin/SurveyInvitesPage.tsx frontend/src/api/admin.ts frontend/src/components/admin/AdminSidebar.tsx frontend/src/router.tsx
git commit -m "feat: add admin survey invites page with create and list"
```
---
## Phase 3: Testing
### Task 9: Backend Tests
**Files:**
- Modify: `backend/tests/test_survey.py`
**Step 1: Add invite-related tests**
Append to `backend/tests/test_survey.py`:
```python
@pytest.mark.asyncio
async def test_check_invite_status_not_found(client):
"""Invalid token returns 404."""
response = await client.get("/api/v1/survey/invite/nonexistent")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_submit_with_completed_token_returns_409(client, admin_auth_headers):
"""Submitting with an already-used token returns 409."""
# Create invite
create_res = await client.post(
"/api/v1/admin/survey-invites",
json={"recipient_name": "Test User"},
headers=admin_auth_headers,
)
assert create_res.status_code == 200
token = create_res.json()["token"]
# First submit succeeds
submit_res = await client.post(
"/api/v1/survey/submit",
json={"responses": {"q1": "answer"}, "token": token},
)
assert submit_res.status_code == 200
# Second submit with same token returns 409
submit_res2 = await client.post(
"/api/v1/survey/submit",
json={"responses": {"q1": "answer"}, "token": token},
)
assert submit_res2.status_code == 409
@pytest.mark.asyncio
async def test_create_invite_requires_admin(client, auth_headers):
"""Non-admin users cannot create invites."""
response = await client.post(
"/api/v1/admin/survey-invites",
json={"recipient_name": "Test"},
headers=auth_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_create_and_list_invites(client, admin_auth_headers):
"""Admin can create and list invites."""
# Create
create_res = await client.post(
"/api/v1/admin/survey-invites",
json={"recipient_name": "John Smith", "recipient_email": "john@msp.example.com"},
headers=admin_auth_headers,
)
assert create_res.status_code == 200
data = create_res.json()
assert data["recipient_name"] == "John Smith"
assert data["status"] == "pending"
assert "survey_url" in data
# List
list_res = await client.get("/api/v1/admin/survey-invites", headers=admin_auth_headers)
assert list_res.status_code == 200
invites = list_res.json()
assert len(invites) >= 1
```
**Step 2: Run tests**
```bash
cd backend && pytest tests/test_survey.py -v --override-ini="addopts="
```
**Step 3: Commit**
```bash
git add backend/tests/test_survey.py
git commit -m "test: add survey invite tracking tests"
```
---
## Summary
| Phase | Tasks | New Files | Modified Files |
|-------|-------|-----------|----------------|
| Backend | Model, migration, schemas, public endpoints, admin endpoints, email | `models/survey_invite.py`, `api/endpoints/admin_survey.py`, migration 047 | `models/__init__.py`, `models/survey_response.py`, `alembic/env.py`, `schemas/survey.py`, `api/endpoints/survey.py`, `api/router.py`, `core/email.py` |
| Frontend | Survey token handling, admin page | `pages/admin/SurveyInvitesPage.tsx` | `pages/SurveyPage.tsx`, `api/admin.ts`, `components/admin/AdminSidebar.tsx`, `router.tsx` |
| Testing | Backend tests | — | `tests/test_survey.py` |
**Key constraints:**
- Public survey still works without a token
- One submission per invite token (409 on reuse)
- Frontend gates completed tokens with "already submitted" screen
- Admin endpoints require `super_admin` role
- Email uses Slate & Ice design (cyan gradient CTA button, dark bg)

View File

@@ -0,0 +1,747 @@
# Admin Survey Responses Page — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build an admin page to view submitted survey responses with expandable row detail and CSV export.
**Architecture:** New backend endpoints on the existing `admin_survey.py` router + new frontend page following the `SurveyInvitesPage` pattern. Expandable rows show Q&A detail inline. CSV export endpoint returns a downloadable file.
**Tech Stack:** FastAPI, SQLAlchemy async, Pydantic v2, React, Tailwind CSS, Lucide icons
---
### Task 1: Backend — Survey response schemas
**Files:**
- Modify: `backend/app/schemas/survey.py`
**Step 1: Add response schemas to survey.py**
Add these schemas at the end of `backend/app/schemas/survey.py`:
```python
class SurveyResponseDetail(BaseModel):
"""Full survey response returned to admin."""
id: str
respondent_name: Optional[str]
responses: dict[str, Any]
source: str # "invite" or "direct"
invite_name: Optional[str]
created_at: datetime
class SurveyResponseListResponse(BaseModel):
"""List of survey responses with summary stats."""
responses: list[SurveyResponseDetail]
total: int
this_week: int
```
**Step 2: Commit**
```bash
git add backend/app/schemas/survey.py
git commit -m "feat: add survey response admin schemas"
```
---
### Task 2: Backend — List survey responses endpoint
**Files:**
- Modify: `backend/app/api/endpoints/admin_survey.py`
**Step 1: Write the failing test**
Add to `backend/tests/test_survey.py`:
```python
@pytest.mark.asyncio
async def test_list_survey_responses_admin(client, admin_auth_headers):
"""Admin can list survey responses."""
# Submit a response first
await client.post(
"/api/v1/survey/submit",
json={"respondent_name": "Tester", "responses": {"q1": "answer"}},
)
res = await client.get("/api/v1/admin/survey-responses", headers=admin_auth_headers)
assert res.status_code == 200
data = res.json()
assert "responses" in data
assert "total" in data
assert "this_week" in data
assert data["total"] >= 1
assert data["responses"][0]["respondent_name"] == "Tester"
assert data["responses"][0]["source"] == "direct"
@pytest.mark.asyncio
async def test_list_survey_responses_requires_admin(client, auth_headers):
"""Non-admin cannot list survey responses."""
res = await client.get("/api/v1/admin/survey-responses", headers=auth_headers)
assert res.status_code == 403
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && python -m pytest tests/test_survey.py::test_list_survey_responses_admin tests/test_survey.py::test_list_survey_responses_requires_admin -v`
Expected: FAIL (404 — endpoint doesn't exist yet)
**Step 3: Implement the endpoint**
Add to `backend/app/api/endpoints/admin_survey.py`:
```python
from datetime import datetime, timezone, timedelta
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse
@router.get("/survey-responses", response_model=SurveyResponseListResponse)
async def list_survey_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey responses with summary stats."""
result = await db.execute(
select(SurveyResponse, SurveyInvite.recipient_name.label("invite_name"))
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
.order_by(SurveyResponse.created_at.desc())
)
rows = result.all()
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
responses = []
this_week = 0
for row in rows:
sr = row[0] # SurveyResponse
inv_name = row[1] # invite_name or None
detail = SurveyResponseDetail(
id=str(sr.id),
respondent_name=sr.respondent_name,
responses=sr.responses,
source="invite" if sr.invite_id else "direct",
invite_name=inv_name,
created_at=sr.created_at,
)
responses.append(detail)
if sr.created_at >= one_week_ago:
this_week += 1
return SurveyResponseListResponse(
responses=responses,
total=len(responses),
this_week=this_week,
)
```
Add the missing imports at the top of `admin_survey.py`:
```python
from datetime import datetime, timezone, timedelta
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_survey.py -v`
Expected: ALL PASS
**Step 5: Commit**
```bash
git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py
git commit -m "feat: add admin list survey responses endpoint"
```
---
### Task 3: Backend — CSV export endpoint
**Files:**
- Modify: `backend/app/api/endpoints/admin_survey.py`
**Step 1: Write the failing test**
Add to `backend/tests/test_survey.py`:
```python
@pytest.mark.asyncio
async def test_export_survey_responses_csv(client, admin_auth_headers):
"""Admin can export survey responses as CSV."""
# Submit a response
await client.post(
"/api/v1/survey/submit",
json={
"respondent_name": "CSV Tester",
"responses": {
"prereqs": ["Who's affected", "What changed recently"],
"verify_fix": "Have the user confirm",
"steps_at_a_time": "5 steps",
"prioritization": ["Likelihood", "Speed", "Blast radius"],
},
},
)
res = await client.get("/api/v1/admin/survey-responses/export", headers=admin_auth_headers)
assert res.status_code == 200
assert "text/csv" in res.headers["content-type"]
assert "attachment" in res.headers.get("content-disposition", "")
body = res.text
assert "CSV Tester" in body
assert "Respondent" in body # Header row
@pytest.mark.asyncio
async def test_export_survey_responses_requires_admin(client, auth_headers):
"""Non-admin cannot export survey responses."""
res = await client.get("/api/v1/admin/survey-responses/export", headers=auth_headers)
assert res.status_code == 403
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && python -m pytest tests/test_survey.py::test_export_survey_responses_csv tests/test_survey.py::test_export_survey_responses_requires_admin -v`
Expected: FAIL (404)
**Step 3: Implement the CSV export endpoint**
Add to `backend/app/api/endpoints/admin_survey.py`:
```python
import csv
import io
from fastapi.responses import StreamingResponse
# Question IDs in survey order, with display labels
SURVEY_QUESTIONS = [
("prereqs", "Q1: Pre-work info needed"),
("verify_fix", "Q2: How you verify a fix"),
("steps_at_a_time", "Q3: Steps at a time preference"),
("first_step", "Q4: First move on vague ticket"),
("junior_mistake", "Q5: Common junior mistake"),
("pivot", "Q6: When to pivot theories"),
("scenario_approach", "Q7: Scenario diagnostic steps"),
("scenario_deeper", "Q8: Scenario server checks"),
("doc_pct", "Q9: Documentation percentage"),
("go_to_commands", "Q10: Go-to commands"),
("secret_weapon", "Q11: Secret weapon"),
("gotcha", "Q12: Wrong obvious diagnosis"),
("hard_rules", "Q13: Hard rules followed"),
("prioritization", "Q14: Diagnostic prioritization"),
("detail_level", "Q15: AI suggestion specificity"),
("ai_personality", "Q16: AI colleague traits"),
]
def _format_answer(value) -> str:
"""Format a survey answer for CSV output."""
if value is None:
return ""
if isinstance(value, list):
# Ranked items: numbered; multi-select: semicolon-joined
if len(value) > 0 and all(isinstance(v, str) for v in value):
return "; ".join(f"{i+1}. {v}" if len(value) > 3 else v for i, v in enumerate(value))
return "; ".join(str(v) for v in value)
return str(value)
@router.get("/survey-responses/export")
async def export_survey_responses_csv(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Export all survey responses as a CSV file."""
result = await db.execute(
select(SurveyResponse).order_by(SurveyResponse.created_at.desc())
)
responses = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
# Header row
headers = ["Respondent", "Date"] + [label for _, label in SURVEY_QUESTIONS]
writer.writerow(headers)
# Data rows
for sr in responses:
row = [
sr.respondent_name or "Anonymous",
sr.created_at.strftime("%Y-%m-%d %H:%M") if sr.created_at else "",
]
for qid, _ in SURVEY_QUESTIONS:
row.append(_format_answer(sr.responses.get(qid)))
writer.writerow(row)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=survey-responses.csv"},
)
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_survey.py -v`
Expected: ALL PASS
**Step 5: Commit**
```bash
git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py
git commit -m "feat: add admin CSV export for survey responses"
```
---
### Task 4: Frontend — Admin API client functions
**Files:**
- Modify: `frontend/src/api/admin.ts`
**Step 1: Add the response type and API functions**
Add the type after the existing `SurveyInviteResponse` interface in `frontend/src/api/admin.ts`:
```typescript
export interface SurveyResponseDetail {
id: string
respondent_name: string | null
responses: Record<string, string | string[]>
source: 'invite' | 'direct'
invite_name: string | null
created_at: string
}
export interface SurveyResponseListResponse {
responses: SurveyResponseDetail[]
total: number
this_week: number
}
```
Add these functions inside the `adminApi` object, after the `createSurveyInvite` entry:
```typescript
// Survey Responses
listSurveyResponses: () =>
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
exportSurveyResponsesCsv: () =>
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
```
**Step 2: Commit**
```bash
git add frontend/src/api/admin.ts
git commit -m "feat: add survey responses admin API client"
```
---
### Task 5: Frontend — Survey Responses admin page
**Files:**
- Create: `frontend/src/pages/admin/SurveyResponsesPage.tsx`
**Step 1: Create the page**
Create `frontend/src/pages/admin/SurveyResponsesPage.tsx`:
```tsx
import { useState, useEffect } from 'react'
import { adminApi } from '@/api/admin'
import type { SurveyResponseDetail } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
// Question metadata for display
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
{ id: 'verify_fix', num: '2', text: 'After you apply a fix, how do you verify it actually worked?', type: 'mc' },
{ id: 'steps_at_a_time', num: '3', text: 'How many steps do you prefer to see at once?', type: 'range' },
{ id: 'first_step', num: '4', text: 'A vague ticket comes in: "Internet is down." What\'s your FIRST move?', type: 'mc' },
{ id: 'junior_mistake', num: '5', text: 'Most common mistake you see junior engineers make?', type: 'mc' },
{ id: 'pivot', num: '6', text: 'How do you decide when to stop pursuing one theory and pivot?', type: 'mc' },
{ id: 'scenario_approach', num: '7', text: 'Walk through your first 3 diagnostic steps for this ticket.', type: 'text' },
{ id: 'scenario_deeper', num: '8', text: 'Server pings fine, you can RDP in. What do you check next?', type: 'text' },
{ id: 'doc_pct', num: '9', text: 'What percentage of troubleshooting steps do you actually document?', type: 'range' },
{ id: 'go_to_commands', num: '10', text: 'Top 3 go-to PowerShell commands or one-liners?', type: 'text' },
{ id: 'secret_weapon', num: '11', text: 'Secret weapon command/tool/technique?', type: 'text' },
{ id: 'gotcha', num: '12', text: 'Issue where the obvious diagnosis was WRONG?', type: 'text' },
{ id: 'hard_rules', num: '13', text: 'Which "rules" do you follow?', type: 'mc-multi' },
{ id: 'prioritization', num: '14', text: 'Rank factors by diagnostic priority influence.', type: 'rank' },
{ id: 'detail_level', num: '15', text: 'How specific should AI diagnostic suggestions be?', type: 'mc' },
{ id: 'ai_personality', num: '16', text: 'What makes an AI feel like a useful colleague?', type: 'mc' },
]
export default function SurveyResponsesPage() {
const [responses, setResponses] = useState<SurveyResponseDetail[]>([])
const [total, setTotal] = useState(0)
const [thisWeek, setThisWeek] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
useEffect(() => {
const load = async () => {
try {
const data = await adminApi.listSurveyResponses()
setResponses(data.responses)
setTotal(data.total)
setThisWeek(data.this_week)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
load()
}, [])
const handleExport = async () => {
setExporting(true)
try {
const blob = await adminApi.exportSurveyResponsesCsv()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'survey-responses.csv'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Export failed')
} finally {
setExporting(false)
}
}
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
})
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<PageHeader
title="Survey Responses"
description={`${total} response${total !== 1 ? 's' : ''} collected`}
/>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed transition-all mt-1"
>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Export CSV
</button>
</div>
{/* Stats */}
<div className="flex gap-4">
<div className="glass-card-static px-5 py-4 flex-1">
<div className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">Total Responses</div>
<div className="text-2xl font-heading font-bold text-gradient-brand">{total}</div>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<div className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">This Week</div>
<div className="text-2xl font-heading font-bold text-foreground">{thisWeek}</div>
</div>
</div>
{error && <p className="text-sm text-rose-500">{error}</p>}
{/* Responses Table */}
<div className="glass-card-static overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="w-10 px-4 py-3" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">#</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Respondent</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Source</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Date</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Answered</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td></tr>
) : responses.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">No responses yet</td></tr>
) : (
responses.map((r, idx) => {
const isExpanded = expandedId === r.id
const answeredCount = Object.keys(r.responses).filter(k => {
const v = r.responses[k]
return v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0)
}).length
return (
<ResponseRow
key={r.id}
response={r}
index={total - idx}
answeredCount={answeredCount}
isExpanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : r.id)}
formatDate={formatDate}
/>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
function ResponseRow({
response: r,
index,
answeredCount,
isExpanded,
onToggle,
formatDate,
}: {
response: SurveyResponseDetail
index: number
answeredCount: number
isExpanded: boolean
onToggle: () => void
formatDate: (d: string) => string
}) {
return (
<>
<tr
onClick={onToggle}
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer group"
>
<td className="px-4 py-3">
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index}</td>
<td className="px-4 py-3 text-sm text-foreground">{r.respondent_name || 'Anonymous'}</td>
<td className="px-4 py-3">
<span className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider',
r.source === 'invite'
? 'bg-primary/10 text-primary'
: 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)}>
{r.source === 'invite' ? <Link2 className="h-3 w-3" /> : <User className="h-3 w-3" />}
{r.source === 'invite' ? r.invite_name || 'Invite' : 'Direct'}
</span>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{formatDate(r.created_at)}</td>
<td className="px-4 py-3">
<span className="font-label text-xs text-muted-foreground">{answeredCount} / {QUESTIONS.length}</span>
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={6} className="p-0">
<ExpandedDetail responses={r.responses} />
</td>
</tr>
)}
</>
)
}
function ExpandedDetail({ responses }: { responses: Record<string, string | string[]> }) {
return (
<div
className="px-6 py-5 animate-fade-in-up"
style={{ background: 'rgba(0, 0, 0, 0.15)', borderTop: '1px solid rgba(6, 182, 212, 0.1)' }}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{QUESTIONS.map(q => {
const answer = responses[q.id]
const hasAnswer = answer !== undefined && answer !== '' && (!Array.isArray(answer) || answer.length > 0)
return (
<div
key={q.id}
className="rounded-[10px] p-4"
style={{
background: 'rgba(24, 26, 31, 0.6)',
border: '1px solid var(--glass-border)',
}}
>
<div className="font-label text-[10px] mb-1 font-medium" style={{ color: '#06b6d4' }}>Q{q.num}</div>
<div className="text-[13px] font-medium text-foreground/80 mb-2 leading-snug">{q.text}</div>
{!hasAnswer ? (
<div className="text-[12px] text-[#5a6170] italic">No answer</div>
) : q.type === 'mc-multi' && Array.isArray(answer) ? (
<div className="flex flex-wrap gap-1.5">
{answer.map((v, i) => (
<span key={i} className="inline-block rounded-full px-2.5 py-0.5 text-[11px] font-label bg-primary/10 text-primary">
{v}
</span>
))}
</div>
) : q.type === 'rank' && Array.isArray(answer) ? (
<ol className="space-y-1">
{answer.map((v, i) => (
<li key={i} className="flex items-center gap-2 text-[12px]">
<span className="font-label text-[10px] font-semibold w-4 text-center" style={{ color: '#06b6d4' }}>{i + 1}</span>
<span className="text-muted-foreground">{v}</span>
</li>
))}
</ol>
) : q.type === 'text' ? (
<div
className="text-[13px] text-muted-foreground leading-relaxed rounded-lg p-2.5"
style={{ background: 'rgba(0, 0, 0, 0.2)', borderLeft: '2px solid rgba(6, 182, 212, 0.2)' }}
>
{String(answer)}
</div>
) : (
<div className="text-[13px] text-muted-foreground">{String(answer)}</div>
)}
</div>
)
})}
</div>
</div>
)
}
```
**Step 2: Verify build**
Run: `cd frontend && npm run build`
Expected: Build succeeds (page isn't routed yet but should compile)
**Step 3: Commit**
```bash
git add frontend/src/pages/admin/SurveyResponsesPage.tsx
git commit -m "feat: add admin survey responses page component"
```
---
### Task 6: Frontend — Wire up route, sidebar, and lazy import
**Files:**
- Modify: `frontend/src/router.tsx`
- Modify: `frontend/src/components/admin/AdminSidebar.tsx`
**Step 1: Add lazy import to router.tsx**
Add after the `AdminSurveyInvitesPage` lazy import (line 54):
```typescript
const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage'))
```
**Step 2: Add route to router.tsx**
Add a new route object after the `survey-invites` route (after line 405):
```typescript
{
path: 'survey-responses',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyResponsesPage />
</Suspense>
),
},
```
**Step 3: Add sidebar nav item**
In `frontend/src/components/admin/AdminSidebar.tsx`:
Add `MessageSquareText` to the lucide import:
```typescript
import {
LayoutDashboard,
Users,
Ticket,
FileText,
Gauge,
ToggleLeft,
Settings,
FolderTree,
ClipboardList,
MessageSquareText,
ArrowLeft,
} from 'lucide-react'
```
Add the nav item after the Survey Invites entry:
```typescript
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
```
**Step 4: Verify build**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors
**Step 5: Commit**
```bash
git add frontend/src/router.tsx frontend/src/components/admin/AdminSidebar.tsx
git commit -m "feat: wire up admin survey responses route and sidebar nav"
```
---
### Task 7: End-to-end verification
**Step 1: Run all backend tests**
Run: `cd backend && python -m pytest tests/test_survey.py -v`
Expected: ALL PASS
**Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds
**Step 3: Manual verification**
1. Start backend: `cd backend && uvicorn app.main:app --reload`
2. Start frontend: `cd frontend && npm run dev`
3. Submit a test survey at `http://localhost:5173/survey`
4. Log in as admin, navigate to Admin > Survey Responses
5. Verify: stats show, table shows response, click to expand, verify Q&A renders
6. Click Export CSV, verify file downloads with correct data
**Step 4: Final commit**
```bash
git add -A
git commit -m "feat: admin survey responses page with expandable detail and CSV export
- Backend: GET /admin/survey-responses (list with stats)
- Backend: GET /admin/survey-responses/export (CSV download)
- Frontend: SurveyResponsesPage with expandable row detail
- Two-column Q&A grid with typed answer rendering
- Stats cards (total, this week)
- CSV export button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```

View File

@@ -0,0 +1,361 @@
# Editor-Embedded Flow Assist - Design Document
> **Date:** 2026-03-06
> **Status:** Approved
> **Replaces:** Standalone AI Chat Builder (`/ai/chat`)
---
## Overview
Replace the standalone `/ai/chat` page with a context-aware AI side panel embedded directly in each editor (Troubleshooting + Procedural). The panel knows which node/step is focused, supports targeted and open-ended actions, and applies changes via a tiered suggestion system. Knowledge integration and variable inference are phased features built on the same panel architecture.
**Key Principles:**
- Context-aware: panel knows the full tree/step structure + focal node
- Targeted actions auto-apply; open-ended suggestions require acceptance
- Output-based thresholds determine suggestion UX
- Model routing is config-driven, not hardcoded
- Chat history persists per-flow, per-user
---
## Panel Layout & Behavior
### Dimensions & Styling
- **Width:** 320px fixed, right side
- **Styling:** Glassmorphism (`.glass-card-static` bg, backdrop blur, `border-l border-border`)
- **Z-index:** Same layer as node editor panel (not overlay)
### Single-Panel Rule
- **Tree editor:** AI panel occupies the right panel slot, closing the node editor panel. When AI panel closes, if a node was previously being edited, the node editor panel reopens for that node.
- **Procedural editor:** AI panel slides in from right, narrowing the step list (step list takes `flex-1`). No existing panel to replace.
### Top Section: Context Summary
- **Node/step selected:** Read-only summary showing type, title, question/description of the focused item.
- **No selection:** Flow summary showing name, node/step count, flow type.
- Switching selection updates the summary live.
### Tabs
- **Chat** — conversation + inline suggestions
- **Suggestions** — audit trail of all AI-applied changes to this flow (accepted, dismissed, pending)
### Visibility
- Hidden by default
- Auto-opens on: AI-assisted flow creation, right-click AI action, toolbar toggle
- Auto-contextual: opens with focal node already set when triggered via context menu
---
## Entry Points
### 1. Create Flow Dropdown (AI-Assisted)
- "Blank" or "AI-assisted" option per flow type (Troubleshooting, Project, Maintenance)
- **AI-assisted** shows a simple prompt dialog modal:
- Text area: "Describe the flow you want to build"
- Flow type already known from dropdown selection
- Loading state during generation
- On failure: error message + retry button (stays in dialog)
- On success: creates tree via API, navigates to editor with AI panel auto-opened and generation chat history loaded
- No multi-phase interview, no preview — just prompt and go
### 2. Right-Click Context Menu
- New `<ContextMenu>` component (no existing context menus in either editor)
- Positioned absolutely at right-click point
- Closes on click-away, Escape, or action selection
- **Tree editor items:** Generate branch, Add decision/action/solution, Explain node, Find known fixes, Delete
- **Procedural editor items:** Generate steps after, Add verification step, Expand step, Generate section, Delete
- Selecting an AI action sets the focal node/step and opens the AI panel
### 3. Toolbar Toggle
- "AI Assist" button in editor toolbar to manually open/close the panel
### 4. Existing Flows
- AI panel works on any flow — new or existing, AI-created or manually built
- No restriction to AI-created flows
---
## Suggestion & Apply System
### Ghost Node/Step Mechanics
Ghost nodes/steps are added to `treeStructure`/steps array with a `_suggestion: true` flag:
- Canvas/step list renders them normally (auto-layout works) but with **dashed borders + reduced opacity**
- Zundo temporal store **paused** while suggestions are pending
- On **accept**: remove `_suggestion` flag, unpause zundo (creates one clean undo point)
- On **dismiss**: remove ghost nodes from structure, unpause zundo (no undo point created)
- Ghost nodes participate in auto-layout and connection drawing but are visually distinct
### Addition vs Modification
| Change Type | Visual Treatment |
|---|---|
| **New nodes/steps** | Ghost nodes: dashed borders, reduced opacity |
| **Modified existing nodes** | Subtle highlight + badge showing what changed |
| **Modified selected node** | Before/after shown in chat message with Apply button (not inline ghost) |
### Output-Based Threshold
| Output Size | Behavior |
|---|---|
| **1 node/step** | Auto-apply + toast notification with undo link |
| **2-4 nodes/steps** | Individual ghost suggestions + "Accept All" shortcut button |
| **5+ nodes/steps** | Ghost suggestions grouped by branch (tree) or section (procedural) with "Accept Branch"/"Accept Section" and "Accept All" controls + summary card in panel |
All changes (accepted or dismissed) logged in the Suggestions tab as an audit trail.
---
## Backend Action Types
Each message to the AI includes an `action_type` that determines prompt construction, response schema, and model routing:
| Action Type | Description | Model Tier | Response Format |
|---|---|---|---|
| `generate_full` | Initial skeleton from prompt dialog | standard | Full tree structure or step array |
| `generate_branch` | Generate children for a specific node | standard | Subtree delta (node + children) |
| `modify_node` | Update a specific node's content | fast | Single node delta (before/after) |
| `add_steps` | Add steps after a specific step | standard | Step array delta |
| `quick_action` | Single-node operations (explain, expand) | fast | Single node delta or text response |
| `open_chat` | General conversation about the flow | standard | Text + optional delta |
| `variable_inference` | Detect implicit variables in step content | fast | Variable suggestions |
### Prompt Construction
Each action type gets a tailored system prompt:
- **Full tree context** always included (so AI understands the complete flow)
- **Focal node** highlighted when present (the specific node/step being acted on)
- **Action instruction** describes what the AI should return
- **Response schema** constrains output format (full tree, subtree delta, single node, text)
### Delta Response Format
For partial updates, the AI returns a delta object:
```json
{
"action": "add" | "modify" | "delete",
"target_node_id": "node-to-modify-or-insert-after",
"nodes": [{ /* node objects */ }],
"explanation": "What was changed and why"
}
```
The frontend applies the delta to the tree structure and renders ghost nodes as appropriate.
---
## Model Routing (Config-Driven)
### Configuration
```python
# backend/app/core/config.py
AI_MODEL_TIERS = {
"fast": "claude-haiku-4-5-20251001",
"standard": "claude-sonnet-4-6-20250514",
}
ACTION_MODEL_MAP = {
"generate_full": "standard",
"generate_branch": "standard",
"modify_node": "fast",
"add_steps": "standard",
"quick_action": "fast",
"open_chat": "standard",
"variable_inference": "fast",
}
```
### Routing Logic
1. Message endpoint receives `action_type` parameter
2. Look up tier from `ACTION_MODEL_MAP`
3. Resolve model name from `AI_MODEL_TIERS`
4. Pass to Anthropic API call
Both tiers can map to the same model initially. Changing model assignment is a config change, not a code change.
---
## Knowledge Integration (Phased)
### Phase 1 (Initial Release)
- Uses existing Microsoft Learn MCP server
- AI can cite KB articles, known issues, and official fix procedures in chat responses
- Citations rendered inline as collapsible cards with source URL and title
- AI response marker: `[KNOWLEDGE]{"title": "...", "url": "...", "excerpt": "..."}[/KNOWLEDGE]`
### Phase 2 (Future)
- Additional vendor documentation sources
- Community knowledge bases
- Proactive suggestions ("Microsoft released KB5034441 addressing this scenario")
---
## Chat Persistence
### Session Model
- `ai_chat_session` model extended with:
- `tree_id` FK (which flow this session belongs to)
- `archived_at` timestamp (null = active)
- Per-flow, per-user sessions: multiple engineers on the same flow get separate chat histories
- Session loads on panel open if one exists for this flow + user
### Suggestions Audit Trail
New `ai_suggestion` table:
| Column | Type | Description |
|---|---|---|
| `id` | UUID | Primary key |
| `tree_id` | UUID FK | Which flow |
| `user_id` | UUID FK | Who triggered |
| `session_id` | UUID FK | Which chat session |
| `action_type` | String | Action that generated this suggestion |
| `target_node_id` | String | Node/step acted on (nullable) |
| `changes_json` | JSONB | Before/after snapshot |
| `status` | Enum | `pending`, `accepted`, `dismissed` |
| `created_at` | DateTime(tz) | When suggested |
| `resolved_at` | DateTime(tz) | When accepted/dismissed (nullable) |
### Auto-Archive
- APScheduler task runs daily
- Archives sessions with no activity for 30 days (`archived_at = now()`)
- Archived sessions viewable in Suggestions tab but not resumable for chat
---
## Troubleshooting Editor Integration
### Panel Context
- Full tree structure included in AI context
- Focal node (when selected/right-clicked) highlighted in context
- Node summary at panel top shows: type icon, node ID, question/title, option count
### Context Menu Actions
| Action | Description | Model Tier |
|---|---|---|
| Generate branch | Create child nodes from this decision | standard |
| Add decision node | Add a decision child | fast |
| Add action node | Add an action child | fast |
| Add solution node | Add a solution child | fast |
| Explain node | AI explains what this node does | fast |
| Find known fixes | Search knowledge sources for this scenario | standard |
### Ghost Node Rendering
- Dashed `border-dashed border-primary/40` borders
- `opacity-60` on the node card
- Connection lines drawn with dashed stroke
- Accept/dismiss buttons overlaid on each ghost node
- "Accept All" button in the panel when 2+ ghost nodes
---
## Procedural Editor Integration
### Panel Context
- Full step list included in AI context
- Focal step (when selected/right-clicked) highlighted in context
- Step summary at panel top shows: step number, type badge, title, content type
### Context Menu Actions
| Action | Description | Model Tier |
|---|---|---|
| Generate steps after | Add steps following this one | standard |
| Add verification step | Insert a verification step | fast |
| Expand step | Break this step into substeps | standard |
| Generate section | Create a section header + steps | standard |
### Ghost Step Rendering
- Dashed left border (`border-l-2 border-dashed border-primary/40`)
- `opacity-60` background
- Accept/dismiss buttons on each ghost step
- Grouped by section when 5+ suggestions
### Intake Variable Detection (Three Tiers)
| Tier | Trigger | Timing | Model Tier |
|---|---|---|---|
| **Explicit** | `[VAR:name]` syntax in step content | Immediate on content save | None (regex match) |
| **Inference** | Natural language suggests variable ("check the customer's server") | Debounced on step save/blur | fast |
| **Cross-step** | Same implicit variable in 2+ steps | On panel open + when steps modified | fast |
**Behavior:**
- Explicit: immediate inline suggestion card in panel ("Add `server_name` to intake form?")
- Inference: non-blocking suggestion in panel, lower confidence indicator
- Cross-step: promoted suggestion with gap flag ("Variable `server_name` used in steps 3, 7, 12 but not captured in intake form")
- Results cached per-session until step content changes
---
## What Gets Removed
| Item | Location |
|---|---|
| `AIChatBuilderPage.tsx` | `frontend/src/pages/` |
| `aiChatStore.ts` | `frontend/src/store/` |
| `ai-chat/` component directory | `frontend/src/components/` |
| `AIFlowBuilderModal` | `frontend/src/components/` |
| `/ai/chat` route | `frontend/src/router.tsx` |
| Flow type selection routing | URL params `?type=...` |
---
## What Gets Repurposed
| Item | Changes |
|---|---|
| `ai_chat_service.py` | Action-type dispatch, partial generation prompts, model routing, focal node context |
| `ai_tree_validator.py` | Validates AI-generated fragments (subtree, step batch) in addition to full trees |
| `ai_chat_session` model | Extended with `tree_id` FK, `archived_at` timestamp |
| AI chat endpoints | Tree-scoped sessions, `action_type` parameter, model tier routing |
---
## What Gets Built (New)
| Item | Description |
|---|---|
| `EditorAIPanel` component | Shared panel with Chat + Suggestions tabs, node summary, input |
| `ContextMenu` component | Shared right-click menu for nodes and steps |
| `useEditorAI` hook | Panel state, focal node, suggestion management, ghost node lifecycle |
| Prompt dialog modal | Simple "describe your flow" modal for AI-assisted create |
| `ai_suggestion` DB model | Audit trail table + Alembic migration |
| Ghost node CSS | Dashed borders, reduced opacity, accept/dismiss overlays |
| Model tier config | `AI_MODEL_TIERS` + `ACTION_MODEL_MAP` in `config.py` |
| APScheduler archive task | Daily job to archive stale sessions |
---
## What Gets Modified
| Item | Changes |
|---|---|
| `TreeEditorPage` | Right panel slot for AI, context menu handler, ghost node support |
| `TreeCanvas` / `TreeCanvasNode` | Ghost node rendering (dashed borders, overlays) |
| `ProceduralEditorPage` | Flex layout for AI panel, context menu on steps |
| `StepList` / `StepEditor` | Ghost step rendering |
| `treeEditorStore` | Ghost node state slice, zundo pause/resume, orphan bug fix |
| `proceduralEditorStore` | Ghost step state slice |
| `ai_chat_service.py` | Action-type dispatch, delta response format, model routing |
| `ai_chat_session` model | `tree_id` FK, `archived_at` |
| `config.py` | Model tier configuration |
| `CreateFlowDropdown` | AI-assisted option + prompt dialog trigger |
| `router.tsx` | Remove `/ai/chat` route |
---
## Bug Fix (Included)
**File:** `frontend/src/store/treeEditorStore.ts` line 858
**Current code:**
```typescript
if (id !== 'root' && !referencedIds.has(id)) {
```
**Fixed code:**
```typescript
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
```
**Root cause:** Orphan check hardcodes `'root'` as the expected root node ID. AI-generated trees use descriptive IDs (e.g., `"verify-account-exists"`). Since the root is never referenced by any other node's `next_node_id`, it gets flagged as orphaned. This is a false positive.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,48 @@
# Glow Edge System — Flow Editor
> **Date:** 2026-03-09
## Overview
Replace flat `smoothstep` edges in the troubleshooting flow editor with custom bezier edges featuring gradient strokes, soft glow, and directional animation on node selection.
## Default Edges (no selection)
- **Curve type:** Bezier (replacing right-angled `smoothstep`)
- **Stroke:** ~1.5px, subtle white/gray gradient with soft `drop-shadow` glow
- **Feel:** Clean, understated, dark-mode friendly
## Selected Node — Downstream Edges
- **Color:** Cyan brand gradient (`#06b6d4``#22d3ee`)
- **Animation:** Flowing dash animation moving downward (`stroke-dashoffset` keyframe)
- **Scope:** All edges from selected node through entire subtree (children, grandchildren, etc.)
- **Glow:** Soft cyan `drop-shadow` filter
- **Stroke:** 2px
## Selected Node — Upstream Edges
- **Color:** Amber gradient (`#f59e0b``#fbbf24`)
- **Animation:** Softer pulse/breathing opacity animation moving upward toward root
- **Scope:** All edges from selected node back to root
- **Glow:** Soft amber `drop-shadow` filter
- **Stroke:** 2px
## Cross-reference Edges
- Keep dashed + animated + cyan with arrows
- Use new bezier curves + glow treatment
## Implementation
- One custom `GlowEdge` component registered as the default edge type in React Flow
- `useTreeLayout` passes `edgeState: 'default' | 'downstream' | 'upstream'` in edge data based on `selectedNodeId`
- SVG `linearGradient` + `filter` defined once in a `<defs>` block
- CSS keyframe animation for flowing dash effect
## Files Touched
- **New:** `frontend/src/components/tree-editor/GlowEdge.tsx` (~60 lines)
- **Modified:** `useTreeLayout.ts` — add ancestor/descendant calculation, edge state
- **Modified:** `FlowCanvas.tsx` — register custom edge type
- **Modified:** `index.css` — keyframe animation for flowing dashes

View File

@@ -0,0 +1,139 @@
# Plan: Flexible Intake — Deferred Variables + Prepared Sessions
## Context
The current intake form on procedural flows is a blocking modal that forces engineers to enter all variables before the flow starts. This creates friction because:
- Engineers don't always have all the information upfront
- Information often lives in PSA tickets, RMM tools, or was communicated verbally
- Sometimes a lead/PM has the info and wants to set up the session for an engineer to execute later
**Goal:** Replace the blocking intake modal with two complementary workflows:
1. **Deferred Variables** — start the flow immediately, fill variables inline as you encounter them
2. **Prepared Sessions** — pre-fill variables ahead of time, optionally assign to an engineer, execute later
---
## Design
### Workflow 1: Deferred Variables (Start Now, Fill Later)
- "Start Flow" launches the session immediately — no intake modal
- Variables begin empty
- When a step references `[VAR:server_name]` and it's unfilled, an **inline input prompt** renders in place — visually prominent with the field's label, help text, and styling that stands out (cyan border, slight glow)
- Once filled, the value persists and resolves everywhere in the session
- Engineers can also open a **"Session Variables" side panel** at any time to see/edit all variables
- At **session completion**, if required variables are still empty → soft warning with a prompt to fill them (for complete export documentation), but not a hard block
### Workflow 2: Prepared Sessions (Set Up Ahead, Execute Later)
- From a flow's detail page: "Prepare Session" action opens a form to fill variables + assign an engineer
- Creates a session in `prepared` state — `started_at` is null, variables populated, no steps executed
- **Assignment:** Preparer can assign to a specific engineer on their team (or leave unassigned)
- **Personal queue:** Engineers see prepared sessions assigned to them in a dedicated section (Quick Start page or Session History tab)
- Clicking a prepared session opens the flow with variables pre-populated; execution begins normally
- Unassigned prepared sessions are visible to all team members
### Data Model Changes
**Session model additions:**
- `prepared_by_id` — UUID FK to users, nullable. Who created the prepared session.
- `assigned_to_id` — UUID FK to users, nullable. Who should execute it.
- Use existing convention: `started_at IS NULL` = prepared, `started_at IS NOT NULL, completed_at IS NULL` = active, `completed_at IS NOT NULL` = completed
**Session variables become mutable:**
- `session_variables` is currently write-once at session creation
- New endpoint: `PATCH /sessions/{id}/variables` — updates individual variables during an active session
- Only the session owner (or assigned engineer) can update variables
**Migration:** One migration adding `prepared_by_id` and `assigned_to_id` columns with FK constraints.
### Variable Resolution Changes
**Backend:**
- No changes to export pipeline — it already resolves variables from `session_variables`
- New `PATCH /sessions/{id}/variables` endpoint accepts partial variable updates
- Session creation no longer validates required intake fields (they can be filled later)
**Frontend — `resolveVariables()` in `lib/variableResolver.ts`:**
- Currently returns a plain string with `[VAR:x]` replaced
- New behavior: also identify unresolved variables so `StepDetail` can render inline prompts
**Frontend — `StepDetail.tsx`:**
- When rendering step content, unresolved `[VAR:x]` references render as inline input components
- Inline prompt design: input field with the field's label as placeholder, cyan border, subtle glow background to make them visually prominent and easy to spot
- On blur/enter: calls `PATCH /sessions/{id}/variables` → re-renders step with resolved value
- Lookup field metadata (label, field_type, help_text, options) from the intake form definition in the tree snapshot
**Frontend — Session Variables Panel:**
- Existing "View Parameters" button becomes "Session Variables" — now editable
- Shows all intake form fields with filled/unfilled status
- Unfilled required fields highlighted
- Editing a field here updates the session and re-resolves all visible steps
### API Changes
| Method | Endpoint | Description |
|--------|----------|-------------|
| `PATCH` | `/sessions/{id}/variables` | Update one or more session variables (partial dict merge) |
| `POST` | `/sessions` | Remove required-field validation for intake forms (allow empty start) |
| `GET` | `/sessions` | Add `assigned_to_id` and `status=prepared` filter params |
| `POST` | `/sessions/prepare` | New endpoint: create a prepared session with variables + optional assignee |
### UI Changes
| Location | Change |
|----------|--------|
| **Flow detail page** | "Start Flow" no longer shows intake modal. Add "Prepare Session" option (dropdown or secondary button) |
| **ProceduralNavigationPage** | Remove `IntakeFormModal` gating. Add "Session Variables" panel button. Inline prompts on steps with unfilled variables |
| **StepDetail** | Render inline input prompts for unresolved `[VAR:x]` references |
| **Quick Start page** | New "Prepared for You" section showing assigned prepared sessions |
| **Session History** | New "Prepared" tab/filter showing prepared sessions |
| **Prepare Session form** | New modal/page: select flow, fill variables, assign engineer, save |
| **Session completion** | Soft warning if required variables still empty |
### What Gets Removed
- `IntakeFormModal.tsx` — no longer used as a blocking gate (may repurpose as the "Prepare Session" form)
- Required-field validation in `POST /sessions` for intake form fields
- The `showIntakeForm` / intake modal state in `ProceduralNavigationPage`
---
## Implementation Phases
### Phase 1: Mutable Variables + Inline Prompts
**Files:** `sessions.py`, `variableResolver.ts`, `StepDetail.tsx`, `ProceduralNavigationPage.tsx`
1. Add `PATCH /sessions/{id}/variables` endpoint
2. Remove intake form required-field blocking from `POST /sessions`
3. Update `resolveVariables()` to identify unresolved variables
4. Build inline variable prompt component for `StepDetail`
5. Make "View Parameters" panel editable
6. Remove `IntakeFormModal` gating from `ProceduralNavigationPage`
### Phase 2: Prepared Sessions
**Files:** `sessions.py`, `session.py` (schemas), migration, `PrepareSessionModal.tsx`, `QuickStartPage.tsx`, `SessionHistoryPage.tsx`
1. Migration: add `prepared_by_id`, `assigned_to_id` to sessions table
2. `POST /sessions/prepare` endpoint
3. `GET /sessions` filter support for `assigned_to_id` and prepared status
4. Prepare Session modal/form (reuse IntakeFormModal field rendering)
5. "Prepared for You" section on Quick Start
6. "Prepared" filter on Session History
### Phase 3: Polish
1. Soft completion warning for unfilled required variables
2. Prepared session staleness indicator (optional)
3. Notification when a session is prepared/assigned to you (optional, future)
---
## Verification
- Start a procedural flow without filling any variables → flow starts immediately, no modal
- Navigate to a step with `[VAR:server_name]` → see inline input prompt
- Fill the variable inline → value resolves across all steps
- Open Session Variables panel → see all fields, edit one → reflected in steps
- Prepare a session from flow detail page → assign to another engineer
- Log in as assigned engineer → see prepared session in Quick Start queue
- Click prepared session → flow opens with variables pre-filled, execute normally
- Complete a session with one unfilled required variable → see soft warning
- Export session → variables resolved in output, unfilled ones show as `[VAR:x]` or blank

View File

@@ -0,0 +1,60 @@
# Session Closure from History Page — Design
> **Date:** 2026-03-11
## Problem
Active sessions on the Session History page only have "View Details" and "Resume" buttons. Engineers have no way to close out sessions that were abandoned, resolved externally, or otherwise no longer needed — without resuming the entire flow.
## Design Decisions
- **Outcome model:** Hybrid — reuse existing 4 outcomes (resolved, escalated, workaround, unresolved) + add 2 early-closure outcomes (cancelled, resolved_externally)
- **UX:** Inline popover anchored to a "Close" button on the session card — no modal, no slide panel
- **Scope:** Active sessions only (started but not completed). No bulk close. No AI summary generation.
- **Backend:** No new endpoints or migrations. Expand `SessionOutcome` literal type; existing `POST /sessions/{id}/complete` handles everything.
## Data Model
No new columns. Expand `SessionOutcome` in `backend/app/schemas/session.py`:
```python
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
```
`VARCHAR(20)` on `session.outcome` fits both new values (max 19 chars for `resolved_externally`).
## UI
### Close Button
Appears on active session cards (`started_at` is set, `completed_at` is null), between "View Details" and "Resume":
```
[View Details] [Close] [Resume]
```
Secondary button styling (border, muted text). Not shown on prepared or completed sessions.
### Close Popover
Anchored below the "Close" button:
- **Outcome selector:** `<select>` with 6 options — Resolved, Escalated, Workaround, Unresolved, Cancelled, Resolved Externally
- **Notes:** Optional textarea (2 rows)
- **Confirm:** `bg-gradient-brand`, disabled until outcome selected
- **Cancel / click outside:** Closes popover
- Glass card styling (`glass-card-static` pattern)
On confirm: calls `POST /sessions/{id}/complete` with `{ outcome, outcome_notes }`, updates local state, shows toast.
## Implementation Scope
### Backend (2 files)
1. `backend/app/schemas/session.py` — add new outcome values to `SessionOutcome`
2. Update frontend outcome type to match
### Frontend (2-3 files)
1. `frontend/src/types/` — update `SessionOutcome` TypeScript type
2. `frontend/src/pages/SessionHistoryPage.tsx` — add Close button, popover, outcome label formatting for new values
No new components, endpoints, or migrations.

View File

@@ -0,0 +1,404 @@
# Session Closure from History Page — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Allow engineers to close active sessions directly from the Session History page via an inline popover with outcome selection and optional notes.
**Architecture:** No new endpoints or migrations. Expand the existing `SessionOutcome` type with two new values (`cancelled`, `resolved_externally`). Add a "Close" button + popover to active session cards on the history page. The popover calls the existing `POST /sessions/{id}/complete` endpoint.
**Tech Stack:** Python/FastAPI (backend schema only), React/TypeScript (frontend UI)
---
### Task 1: Backend — Expand SessionOutcome Type
**Files:**
- Modify: `backend/app/schemas/session.py:6`
**Step 1: Write the failing test**
Add to `backend/tests/test_sessions.py`, inside the existing `TestSessions` class, after the `test_complete_session` test:
```python
@pytest.mark.asyncio
async def test_complete_session_with_cancelled_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test completing a session with 'cancelled' outcome."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "cancelled", "outcome_notes": "Ticket withdrawn by client"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["outcome"] == "cancelled"
assert data["outcome_notes"] == "Ticket withdrawn by client"
assert data["completed_at"] is not None
@pytest.mark.asyncio
async def test_complete_session_with_resolved_externally_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test completing a session with 'resolved_externally' outcome."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"]},
headers=auth_headers
)
session_id = create_response.json()["id"]
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved_externally"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["outcome"] == "resolved_externally"
assert data["completed_at"] is not None
```
**Step 2: Run tests to verify they fail**
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py::TestSessions::test_complete_session_with_cancelled_outcome tests/test_sessions.py::TestSessions::test_complete_session_with_resolved_externally_outcome -v`
Expected: FAIL — 422 validation error because `cancelled` and `resolved_externally` are not valid `SessionOutcome` values.
**Step 3: Update SessionOutcome literal**
In `backend/app/schemas/session.py`, line 6, change:
```python
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
```
to:
```python
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
```
No other backend changes needed — the `POST /sessions/{id}/complete` endpoint, the `Session` model (`VARCHAR(20)`), and the `SessionComplete` schema all work with the new values automatically.
**Step 4: Run tests to verify they pass**
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py::TestSessions::test_complete_session_with_cancelled_outcome tests/test_sessions.py::TestSessions::test_complete_session_with_resolved_externally_outcome -v`
Expected: PASS
**Step 5: Run full test suite to check for regressions**
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py -v`
Expected: All session tests PASS.
**Step 6: Commit**
```bash
git add backend/app/schemas/session.py backend/tests/test_sessions.py
git commit -m "feat: add cancelled and resolved_externally session outcomes"
```
---
### Task 2: Frontend — Update SessionOutcome Type
**Files:**
- Modify: `frontend/src/types/session.ts:4`
**Step 1: Update the TypeScript type**
In `frontend/src/types/session.ts`, line 4, change:
```typescript
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
```
to:
```typescript
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved' | 'cancelled' | 'resolved_externally'
```
**Step 2: Verify build**
Run: `cd frontend && npm run build`
Expected: Clean build, no errors.
**Step 3: Commit**
```bash
git add frontend/src/types/session.ts
git commit -m "feat: add cancelled and resolved_externally to frontend SessionOutcome type"
```
---
### Task 3: Frontend — Add Close Button and Popover to Session History
**Files:**
- Modify: `frontend/src/pages/SessionHistoryPage.tsx`
This is the main UI task. Add a "Close" button to active session cards and an inline popover with outcome selection + notes.
**Step 1: Add state and handler**
At the top of the `SessionHistoryPage` component (after existing `useState` calls around line 25), add:
```tsx
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
const [closeNotes, setCloseNotes] = useState('')
const [closeLoading, setCloseLoading] = useState(false)
```
Add the import for `SessionOutcome` to the existing type import on line 7:
```typescript
import type { Session, TreeListItem, SessionOutcome } from '@/types'
```
Add `useRef` to the React import on line 1:
```typescript
import { useEffect, useState, useRef, useCallback } from 'react'
```
Add the close handler function inside the component, after `handleClearFilters`:
```tsx
const closePopoverRef = useRef<HTMLDivElement>(null)
const handleCloseSession = useCallback(async () => {
if (!closingSessionId || !closeOutcome) return
setCloseLoading(true)
try {
await sessionsApi.complete(closingSessionId, {
outcome: closeOutcome,
outcome_notes: closeNotes || undefined,
})
// Update local state — mark session as completed
setSessions(prev =>
prev.map(s =>
s.id === closingSessionId
? { ...s, completed_at: new Date().toISOString(), outcome: closeOutcome, outcome_notes: closeNotes || null }
: s
)
)
toast.success('Session closed')
setClosingSessionId(null)
setCloseOutcome('')
setCloseNotes('')
} catch {
toast.error('Failed to close session')
} finally {
setCloseLoading(false)
}
}, [closingSessionId, closeOutcome, closeNotes])
// Close popover on click outside
useEffect(() => {
if (!closingSessionId) return
const handleClickOutside = (e: MouseEvent) => {
if (closePopoverRef.current && !closePopoverRef.current.contains(e.target as Node)) {
setClosingSessionId(null)
setCloseOutcome('')
setCloseNotes('')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [closingSessionId])
```
**Step 2: Update formatOutcomeLabel**
In `SessionHistoryPage.tsx`, update the `formatOutcomeLabel` function (around line 158) to handle new outcomes:
```tsx
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
if (!outcome) return 'Not set'
const labels: Record<string, string> = {
resolved: 'Resolved',
escalated: 'Escalated',
workaround: 'Workaround',
unresolved: 'Unresolved',
cancelled: 'Cancelled',
resolved_externally: 'Resolved Externally',
}
return labels[outcome] ?? outcome
}
```
**Step 3: Add outcome badge colors for new outcomes**
In the session card JSX (around line 249-258), update the outcome badge to handle new outcomes. Add these two lines inside the `cn()` call, after the `!session.outcome` line:
```tsx
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
```
**Step 4: Add Close button and popover to the Actions div**
In the session card's Actions div (around line 288-309), replace the entire `{/* Actions */}` block with:
```tsx
{/* Actions */}
<div className="relative flex gap-2">
<button
onClick={() => navigate(`/sessions/${session.id}`)}
className={cn(
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
>
View Details
</button>
{!session.completed_at && session.started_at && (
<>
<button
onClick={() => {
setClosingSessionId(closingSessionId === session.id ? null : session.id)
setCloseOutcome('')
setCloseNotes('')
}}
className={cn(
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground',
closingSessionId === session.id && 'bg-accent text-foreground'
)}
>
Close
</button>
<button
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className={cn(
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90'
)}
>
Resume
</button>
</>
)}
{/* Close Session Popover */}
{closingSessionId === session.id && (
<div
ref={closePopoverRef}
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
>
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
<label className="block text-xs font-label text-muted-foreground mb-1">Outcome</label>
<select
value={closeOutcome}
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none mb-3"
>
<option value="">Select outcome...</option>
<option value="resolved">Resolved</option>
<option value="escalated">Escalated</option>
<option value="workaround">Workaround</option>
<option value="unresolved">Unresolved</option>
<option value="cancelled">Cancelled</option>
<option value="resolved_externally">Resolved Externally</option>
</select>
<label className="block text-xs font-label text-muted-foreground mb-1">Notes (optional)</label>
<textarea
value={closeNotes}
onChange={(e) => setCloseNotes(e.target.value)}
rows={2}
placeholder="Add closure notes..."
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none mb-3"
/>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
setClosingSessionId(null)
setCloseOutcome('')
setCloseNotes('')
}}
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
onClick={handleCloseSession}
disabled={!closeOutcome || closeLoading}
className={cn(
'rounded-lg px-4 py-1.5 text-sm font-medium shadow-lg shadow-primary/20 transition-opacity',
closeOutcome
? 'bg-gradient-brand text-[#101114] hover:opacity-90'
: 'bg-gradient-brand text-[#101114] opacity-50 cursor-not-allowed'
)}
>
{closeLoading ? 'Closing...' : 'Confirm'}
</button>
</div>
</div>
)}
</div>
```
**Step 5: Verify build**
Run: `cd frontend && npm run build`
Expected: Clean build, no errors.
**Step 6: Commit**
```bash
git add frontend/src/pages/SessionHistoryPage.tsx
git commit -m "feat: add close session button with inline popover on history page"
```
---
### Task 4: Verify Full Stack
**Step 1: Run backend tests**
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py -v`
Expected: All tests PASS including the 2 new ones.
**Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: Clean build.
**Step 3: Manual smoke test**
1. Open http://localhost:5173/sessions
2. Find an active session (yellow dot, no completed_at)
3. Verify "Close" button appears between "View Details" and "Resume"
4. Click "Close" — popover appears below
5. Select "Cancelled" outcome, type a note
6. Click "Confirm" — session card updates with completed status + "Cancelled" badge
7. Verify "Close" and "Resume" buttons disappear (session is now completed)
8. Verify completed sessions do NOT show a "Close" button
9. Verify prepared sessions (not yet started) do NOT show a "Close" button
**Step 4: Final commit (if any adjustments needed)**
```bash
git add -A
git commit -m "fix: session closure adjustments from smoke test"
```

View File

@@ -0,0 +1,157 @@
# Script Template Editor — Design Document
> **Date:** 2026-03-13
> **Status:** Approved
> **Depends on:** Script Generator Phase 1 (backend) + Phase 2 (Script Library frontend)
---
## Goal
Build a Script Template Editor page where engineers can create and manage their own PowerShell script templates, and owners/admins can promote personal templates to team-wide visibility via a "Share with team" toggle.
---
## Page Structure
**Route:** `/scripts/manage`
**Two modes:**
- **List mode** — all templates the user can see/edit. Filterable by category, searchable by name. Each row: name, category, complexity badge, usage count, scope badge ("Personal" / "Team"), action buttons (Edit, Delete).
- **Editor mode** — full-page form for creating or editing a template. No modal — the template has too many fields. "Back to templates" link with unsaved-changes warning if dirty.
**Navigation entry points:**
- "Manage Templates" link on the Script Library page header (visible to engineers+)
- Direct URL `/scripts/manage`
---
## Permissions
### Who sees what in the list
| Role | Visible templates |
|------|------------------|
| Engineer | Own templates (`created_by = user.id`) + team templates (`team_id = user.team_id`) |
| Owner / Admin | All templates in their account scope |
| Super admin | All templates across all accounts |
### Who can do what
| Action | Engineer | Owner/Admin | Super Admin |
|--------|----------|-------------|-------------|
| Create template | Yes (personal scope) | Yes (personal or team) | Yes (any scope) |
| Edit own template | Yes | Yes | Yes |
| Edit others' templates | No | Yes (within account) | Yes (all) |
| Delete own template | Yes (soft delete) | Yes | Yes |
| Delete others' templates | No | Yes (within account) | Yes (all) |
| "Share with team" toggle | No | Yes | Yes |
---
## Backend Changes
### Permission refactor
Replace `_require_team_admin()` with `_check_template_permission(user, template?)`:
- **Create:** engineers+ can create (personal scope)
- **Edit/Delete:** engineers can modify own templates (`created_by == user.id`); owners/admins can modify any template in their account; super admins can modify any
- Existing `POST /scripts/templates` sets `created_by = user.id` and `team_id = null` for engineers (personal scope)
### New endpoint
`PATCH /scripts/templates/{id}/share` — owner/admin/super_admin only
- Body: `{ "shared": boolean }`
- `shared=true` → sets `team_id` to the user's `team_id`
- `shared=false` → clears `team_id` to `null` (reverts to personal scope for original author)
- Returns updated `ScriptTemplateDetail`
### Query filter
Update `GET /scripts/templates` to support `managed=true` query param:
- Returns templates the user can edit (own templates + team templates for owners/admins)
- Used by the manage page list view
---
## Template Editor Form
Single scrollable page with sections separated by dividers. Fixed bottom action bar.
### Section 1: Metadata
- **Name** — text input, required
- **Description** — textarea
- **Use Case** — textarea ("when would you use this?")
- **Category** — select dropdown from existing categories
- **Complexity** — select: beginner / intermediate / advanced
- **Tags** — multi-text input (comma-separated or chip-style)
- **Estimated Runtime** — text input (e.g., "30 seconds")
- **Requires Elevation** — checkbox
- **Required Modules** — multi-text input
- **Share with team** — toggle switch, visible only to owners/admins/super_admins. Help text: "When enabled, all team members can browse and use this template."
### Section 2: Script Body
- Large textarea with `PowerShellHighlighter` for syntax coloring
- Monospace font (`font-label` / JetBrains Mono)
- `{{parameter_key}}` placeholders highlighted in amber so authors can see where parameters slot in
- Simple textarea with highlighting overlay (no Monaco/CodeMirror dependency)
### Section 3: Parameters Schema
Two modes with a toggle at the top of the section:
**Visual mode (default):**
- List of parameter cards, each expandable/collapsible
- "Add Parameter" button at bottom
- Each card: key, label, type (select from 7 types), required toggle, placeholder, group, order, help_text, default value, sensitive toggle
- For `select` type: options sub-list (value + label pairs)
- For types with validation: min/max/pattern fields
- Drag-to-reorder or up/down arrows for parameter ordering
**JSON mode:**
- Raw JSON editor showing the `parameters_schema` object
- Edits sync back to visual mode on switch
- Parse errors shown inline
### Section 4: Fixed Action Bar
- **Save** — primary button, creates or updates template
- **Cancel** — back to list with dirty-state warning
- **Delete** — danger button (right-aligned), only in edit mode, confirmation modal, soft delete
---
## Team Sharing Behavior
- **Default for engineer-created templates:** `team_id = null` (personal, only visible to creator)
- **Shared:** `team_id` set to account's team — template appears in Script Library for all team members
- **Unsharing:** reverts to personal scope for original author; author retains edit access via `created_by`
---
## Frontend Components (Expected)
| Component | Responsibility |
|-----------|---------------|
| `ScriptManagePage.tsx` | Page shell, list/editor mode toggle |
| `ScriptTemplateListView.tsx` | Template list with filters, search, action buttons |
| `ScriptTemplateEditor.tsx` | Full editor form — metadata, script body, parameters |
| `ParameterSchemaBuilder.tsx` | Visual parameter builder with add/remove/reorder |
| `ParameterCard.tsx` | Single parameter editor (expandable card) |
| `ParameterJsonEditor.tsx` | Raw JSON mode for parameters schema |
| `ScriptBodyEditor.tsx` | Textarea with PowerShell highlighting overlay |
| `ShareToggle.tsx` | Team sharing toggle (owner/admin only) |
---
## Design System Compliance
- Dark glassmorphism theme, `.glass-card-static` containers
- Primary actions: `bg-gradient-brand`
- Section labels: `font-label text-[0.625rem] uppercase tracking-[0.1em]`
- Form inputs: `border-border bg-card text-foreground` with cyan focus ring
- Complexity badges: emerald (beginner), amber (intermediate), rose (advanced)
- Scope badges: "Personal" (muted border) / "Team" (cyan/primary tint)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
# Parameter Detector — Design Doc
> **Date:** 2026-03-14
> **Status:** Approved
> **Scope:** Frontend only (no backend changes)
## Overview
A client-side tool in the Script Template Editor that scans a PowerShell script body, detects hardcoded values that should be parameterized, and walks the user through converting them one-by-one via a stepper UI.
## Detection Engine
Pure TypeScript utility (`lib/scriptParameterDetector.ts`) that takes a script body string and returns `ParameterCandidate[]`.
### Detection targets (in order)
1. **Script-level `param()` blocks** — the `param(...)` block at the top of the script, before any `function` keyword. Extracts name, type annotation, and default value. Skips `param()` blocks inside `function` declarations.
2. **Variable assignments**`$VarName = 'value'`, `$VarName = "value"`, `$VarName = 123`, `$VarName = $true/$false`. Skips variables already found in the param block and PowerShell internals like `$ErrorActionPreference`.
### Type inference
| Detection | Suggested Type | Sensitive |
|-----------|---------------|-----------|
| `[string]` or plain string value | `text` | no |
| `[switch]` or `$true`/`$false` | `boolean` | no |
| `[int]`, `[int32]`, `[int64]` or numeric value | `number` | no |
| `[SecureString]` or name contains password/secret/key/credential | `password` | yes |
| No type info, string value | `text` | no |
### Candidate shape
```typescript
interface ParameterCandidate {
variableName: string // "$OUPath"
suggestedKey: string // "ou_path"
suggestedLabel: string // "OU Path"
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string // "Detected [switch] type declaration"
}
```
## Stepper UI
`ParameterDetectorStepper` component renders inline below the ScriptBodyEditor in the Script Body section.
### Trigger
- "Detect Parameters" button (secondary style, Wand2/Scan icon) below the script body textarea
- Hidden if script body is empty; disabled while stepper is active
- If no candidates found: brief "No parameter candidates detected" message
### Stepper layout
Shows one candidate at a time with:
- Progress indicator ("Candidate 2 of 5" + dots)
- Matched line displayed in monospace
- Editable fields: Key, Label, Type (with info icon showing inferenceReason), Default value
- Checkboxes: Required, Sensitive
- Actions: Skip, Accept & Next (last item: Accept & Finish / Skip & Finish)
### On accept
1. Script body: replace the hardcoded value with `{{key}}`
2. Parameters schema: append a new `ScriptParameter` with suggested values + original value as `default`
### Edge cases
- Script body edited during detection → stepper dismisses
- Key conflicts with existing parameter → warning + suggested alternative
- Re-running after partial conversion → skips already-converted `{{key}}` placeholders
## Integration
### Component tree
```
ScriptTemplateEditor
├── Metadata section
├── Script Body section
│ ├── ScriptBodyEditor
│ ├── "Detect Parameters" button ← NEW
│ └── ParameterDetectorStepper ← NEW (conditional)
├── Parameters section
│ └── ParameterSchemaBuilder
└── Fixed Action Bar
```
### Data flow
- Detection runs client-side via `detectParameterCandidates(script_body)`
- Candidates stored in local React state on ScriptTemplateEditor
- Accept updates `form.script_body` and `form.parameters_schema` via existing `updateField()`
- `isDirty` flag set automatically — user can cancel without saving to undo everything
- No new backend endpoints needed
## File changes
**New files:**
- `frontend/src/lib/scriptParameterDetector.ts`
- `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
**Modified files:**
- `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
- `frontend/src/types/scripts.ts`
## Out of scope
- No backend changes
- No AI-powered detection (future enhancement)
- No auto-detection on paste
- No individual undo for accepted parameters

View File

@@ -0,0 +1,920 @@
# Parameter Detector Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a client-side PowerShell parameter detection tool to the Script Template Editor that scans script bodies for hardcoded values and walks users through converting them to template parameters via a stepper UI.
**Architecture:** Pure frontend feature. A detection engine (`lib/scriptParameterDetector.ts`) parses PowerShell script bodies using regex to find script-level `param()` block entries and variable assignments. A stepper component (`ParameterDetectorStepper`) presents candidates one-by-one for review. Accepted candidates update both `form.script_body` (value → `{{key}}`) and `form.parameters_schema` (new `ScriptParameter` appended).
**Tech Stack:** TypeScript, React, Lucide icons, Tailwind CSS, existing ScriptParameter types
**Design doc:** `docs/plans/2026-03-14-parameter-detector-design.md`
---
### Task 1: Add ParameterCandidate type
**Files:**
- Modify: `frontend/src/types/scripts.ts:124` (append after `ScriptTemplateUpdateRequest`)
**Step 1: Add the interface**
Add to the end of `frontend/src/types/scripts.ts`:
```typescript
export interface ParameterCandidate {
variableName: string
suggestedKey: string
suggestedLabel: string
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string
}
```
**Step 2: Export from types index**
Verify `ParameterCandidate` is exported from `frontend/src/types/index.ts`. If scripts types are re-exported with `export * from './scripts'`, it's automatic. Otherwise add the export.
**Step 3: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 4: Commit**
```bash
git add frontend/src/types/scripts.ts
git commit -m "feat: add ParameterCandidate type for script parameter detection"
```
---
### Task 2: Build detection engine
**Files:**
- Create: `frontend/src/lib/scriptParameterDetector.ts`
**Step 1: Create the detection utility**
Create `frontend/src/lib/scriptParameterDetector.ts` with the following:
```typescript
import type { ScriptParameter, ParameterCandidate } from '@/types'
/**
* PowerShell variable names to skip — these are PS internals, not user inputs.
*/
const SKIP_VARIABLES = new Set([
'$ErrorActionPreference',
'$WarningPreference',
'$VerbosePreference',
'$DebugPreference',
'$InformationPreference',
'$ConfirmPreference',
'$ProgressPreference',
'$PSDefaultParameterValues',
'$PSModuleAutoLoadingPreference',
'$OFS',
'$FormatEnumerationLimit',
'$MaximumHistoryCount',
'$_',
'$PSItem',
'$args',
'$input',
'$this',
'$null',
'$true',
'$false',
])
/**
* Sensitive variable name patterns — if the variable name contains any of these,
* suggest password type and mark sensitive.
*/
const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i
/**
* Convert a PowerShell variable name to a snake_case key.
* "$OUPath" → "ou_path", "$ServerName" → "server_name"
*/
function toSnakeCase(varName: string): string {
// Strip leading $
const name = varName.replace(/^\$/, '')
// Insert underscore before uppercase letters, then lowercase everything
return name
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
}
/**
* Convert a snake_case key to a human-readable label.
* "ou_path" → "OU Path", "server_name" → "Server Name"
*/
function toLabel(key: string): string {
return key
.split('_')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/**
* Infer the ScriptParameter type from a PowerShell type annotation and/or value.
*/
function inferType(
typeAnnotation: string | null,
value: string | null,
varName: string
): { type: ScriptParameter['type']; sensitive: boolean; reason: string } {
// Check type annotation first
if (typeAnnotation) {
const t = typeAnnotation.toLowerCase()
if (t === 'switch') {
return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' }
}
if (t === 'securestring') {
return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' }
}
if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') {
return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
if (t === 'bool' || t === 'boolean') {
return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
// [string] or other → fall through to value/name checks
}
// Check variable name for sensitive patterns
if (SENSITIVE_PATTERNS.test(varName)) {
return { type: 'password', sensitive: true, reason: `Variable name suggests sensitive data — marked as sensitive` }
}
// Check value patterns
if (value !== null) {
const trimmed = value.trim()
if (trimmed === '$true' || trimmed === '$false') {
return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' }
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return { type: 'number', sensitive: false, reason: 'Detected numeric value' }
}
}
// Default
const reason = typeAnnotation
? `Detected [${typeAnnotation}] type declaration`
: 'Defaulting to text (no type annotation detected)'
return { type: 'text', sensitive: false, reason }
}
/**
* Parse the default value into the correct JS type.
*/
function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null {
if (value === null) return null
const trimmed = value.trim()
if (type === 'boolean') {
if (trimmed === '$true') return true
if (trimmed === '$false') return false
return null
}
if (type === 'number') {
const n = Number(trimmed)
return isNaN(n) ? null : n
}
// Strip surrounding quotes for string values
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1)
}
return trimmed
}
/**
* Find the end index of a script-level param() block.
* Returns -1 if no script-level param block is found.
* Skips param() blocks inside function declarations.
*/
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
const lines = script.split('\n')
let inFunction = false
let paramStart = -1
let parenDepth = 0
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
// Track function blocks — skip param() inside functions
if (/^function\s+/i.test(trimmed)) {
inFunction = true
continue
}
// Find script-level param keyword
if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
paramStart = i
// Count parens to find the closing )
for (let j = i; j < lines.length; j++) {
for (const ch of lines[j]) {
if (ch === '(') parenDepth++
if (ch === ')') parenDepth--
if (parenDepth === 0 && paramStart !== -1) {
return { start: paramStart, end: j }
}
}
}
}
// Reset function tracking at closing brace (simplified)
if (inFunction && trimmed === '}') {
inFunction = false
}
}
return null
}
/**
* Extract parameter candidates from a script-level param() block.
*/
function extractParamBlockCandidates(
script: string,
block: { start: number; end: number }
): ParameterCandidate[] {
const lines = script.split('\n')
const blockText = lines.slice(block.start, block.end + 1).join('\n')
const candidates: ParameterCandidate[] = []
// Match patterns like: [string]$VarName = "default" or $VarName or [switch]$VarName
// Supports [Parameter(Mandatory=$true)] attributes on preceding lines
const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm
let match: RegExpExecArray | null
while ((match = paramRegex.exec(blockText)) !== null) {
const typeAnnotation = match[1] || null
const varName = match[2]
const rawDefault = match[3]?.trim() ?? null
// Skip Parameter() attributes — they look like [Parameter(...)]
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
const defaultValue = parseDefault(rawDefault, type)
// Find the actual line number in the original script
const lineIndex = lines.findIndex((line, idx) =>
idx >= block.start && idx <= block.end && line.includes(`$${varName}`)
)
candidates.push({
variableName: `$${varName}`,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'param_block',
lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1,
matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`,
inferenceReason: reason,
})
}
return candidates
}
/**
* Extract parameter candidates from variable assignments ($Var = 'value').
*/
function extractAssignmentCandidates(
script: string,
existingVarNames: Set<string>
): ParameterCandidate[] {
const lines = script.split('\n')
const candidates: ParameterCandidate[] = []
const seenVars = new Set<string>()
// Match: $VarName = 'value' | "value" | 123 | $true | $false
const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(assignRegex)
if (!match) continue
const fullVar = match[1]
const rawValue = match[2].trim()
// Skip PS internals
if (SKIP_VARIABLES.has(fullVar)) continue
// Skip if already found in param block
if (existingVarNames.has(fullVar)) continue
// Skip if already seen (take first assignment only)
if (seenVars.has(fullVar)) continue
// Skip if value is a complex expression (function call, pipeline, etc.)
// Only match: quoted strings, numbers, $true/$false
if (!/^['"].*['"]$/.test(rawValue) &&
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
!/^\$(true|false)$/i.test(rawValue)) {
continue
}
// Skip if the value already contains a {{placeholder}}
if (/\{\{.*?\}\}/.test(rawValue)) continue
seenVars.add(fullVar)
const varName = fullVar.replace(/^\$/, '')
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(null, rawValue, varName)
const defaultValue = parseDefault(rawValue, type)
candidates.push({
variableName: fullVar,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'assignment',
lineNumber: i + 1,
matchedLine: lines[i].trim(),
inferenceReason: reason,
})
}
return candidates
}
/**
* Detect parameter candidates in a PowerShell script body.
* Returns candidates from script-level param() block first, then variable assignments.
*/
export function detectParameterCandidates(script: string): ParameterCandidate[] {
if (!script.trim()) return []
// 1. Find and extract script-level param block
const paramBlock = findScriptLevelParamBlock(script)
const paramCandidates = paramBlock
? extractParamBlockCandidates(script, paramBlock)
: []
// Track param block var names to avoid duplicates in assignment scan
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
// 2. Extract variable assignments
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
return [...paramCandidates, ...assignmentCandidates]
}
```
**Step 2: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 3: Commit**
```bash
git add frontend/src/lib/scriptParameterDetector.ts
git commit -m "feat: add PowerShell parameter detection engine"
```
---
### Task 3: Build ParameterDetectorStepper component
**Files:**
- Create: `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
**Step 1: Create the stepper component**
Create `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`:
```tsx
import { useState } from 'react'
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import type { ParameterCandidate, ScriptParameter } from '@/types'
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'password', label: 'Password' },
{ value: 'textarea', label: 'Textarea' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'select', label: 'Select' },
{ value: 'multi_text', label: 'Multi-text' },
]
interface Props {
candidates: ParameterCandidate[]
existingKeys: string[]
onAccept: (candidate: ParameterCandidate, overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}) => void
onSkip: (candidate: ParameterCandidate) => void
onFinish: (acceptedCount: number, totalCount: number) => void
}
export function ParameterDetectorStepper({
candidates,
existingKeys,
onAccept,
onSkip,
onFinish,
}: Props) {
const [currentIndex, setCurrentIndex] = useState(0)
const [acceptedCount, setAcceptedCount] = useState(0)
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
// Editable overrides for the current candidate
const current = candidates[currentIndex]
const [key, setKey] = useState(current.suggestedKey)
const [label, setLabel] = useState(current.suggestedLabel)
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
const [sensitive, setSensitive] = useState(current.sensitive)
const [required, setRequired] = useState(true)
const [defaultValue, setDefaultValue] = useState(
current.defaultValue !== null ? String(current.defaultValue) : ''
)
const isLast = currentIndex === candidates.length - 1
const keyConflict = existingKeys.includes(key) ||
candidates.slice(0, currentIndex).some((_, i) => {
// This is a simplification — actual conflict check happens against
// the running list of accepted keys which is managed by the parent
return false
})
const resetFieldsForIndex = (index: number) => {
const c = candidates[index]
setKey(c.suggestedKey)
setLabel(c.suggestedLabel)
setType(c.suggestedType)
setSensitive(c.sensitive)
setRequired(true)
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
setShowInferenceInfo(false)
}
const handleAccept = () => {
const parsedDefault = type === 'boolean'
? defaultValue === 'true'
: type === 'number'
? (defaultValue ? Number(defaultValue) : null)
: (defaultValue || null)
onAccept(current, {
key,
label,
type,
sensitive,
required,
defaultValue: parsedDefault,
})
const newAccepted = acceptedCount + 1
setAcceptedCount(newAccepted)
if (isLast) {
onFinish(newAccepted, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
const handleSkip = () => {
onSkip(current)
if (isLast) {
onFinish(acceptedCount, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
return (
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
i < currentIndex ? 'bg-primary' :
i === currentIndex ? 'bg-primary animate-pulse' :
'bg-border'
)}
/>
))}
</div>
</div>
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-label text-xs text-amber-400 break-all">
{current.matchedLine}
</p>
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
</p>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
<Input
value={key}
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Display Label"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
Type
<button
type="button"
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
className="text-muted-foreground hover:text-primary transition-colors"
title={current.inferenceReason}
>
<Info size={11} />
</button>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{showInferenceInfo && (
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
{current.inferenceReason}
</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={defaultValue}
onChange={e => setDefaultValue(e.target.value)}
placeholder="Original value preserved"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={required}
onChange={e => setRequired(e.target.checked)}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={sensitive}
onChange={e => setSensitive(e.target.checked)}
className="rounded border-border"
/>
Sensitive
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
<button
type="button"
onClick={handleSkip}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
>
<SkipForward size={13} />
{isLast ? 'Skip & Finish' : 'Skip'}
</button>
<button
type="button"
onClick={handleAccept}
disabled={!key.trim() || !label.trim()}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLast ? (
<><Check size={13} /> Accept &amp; Finish</>
) : (
<><ChevronRight size={13} /> Accept &amp; Next</>
)}
</button>
</div>
</div>
)
}
```
**Step 2: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 3: Commit**
```bash
git add frontend/src/components/script-editor/ParameterDetectorStepper.tsx
git commit -m "feat: add ParameterDetectorStepper component"
```
---
### Task 4: Wire detection into ScriptTemplateEditor
**Files:**
- Modify: `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
**Step 1: Add imports**
At the top of `ScriptTemplateEditor.tsx`, add:
```typescript
import { Scan } from 'lucide-react'
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
import type { ParameterCandidate, ScriptParameter } from '@/types'
```
Update the existing `lucide-react` import to include `Scan` alongside the existing icons.
**Step 2: Add detection state**
Inside the `ScriptTemplateEditor` component, after the existing `useState` declarations (around line 59), add:
```typescript
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
```
**Step 3: Add detection handler**
After `handleBack` (around line 188), add:
```typescript
const handleDetectParameters = () => {
const candidates = detectParameterCandidates(form.script_body)
if (candidates.length === 0) {
setDetectionSummary('No parameter candidates detected in the script body.')
setShowStepper(false)
setTimeout(() => setDetectionSummary(null), 4000)
return
}
setDetectedCandidates(candidates)
setDetectionSummary(null)
setShowStepper(true)
}
const handleAcceptCandidate = (
candidate: ParameterCandidate,
overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}
) => {
// 1. Replace the value in the script body with {{key}}
let updatedScript = form.script_body
if (candidate.source === 'param_block') {
// For param block: replace the default value portion
// e.g., $VarName = "default" → $VarName = "{{key}}"
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
if (defaultMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
)
}
} else {
// For assignment: replace the right-hand side value
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
if (assignMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
)
}
}
// 2. Append new parameter to the schema
const existingParams = form.parameters_schema.parameters
const newParam: ScriptParameter = {
key: overrides.key,
label: overrides.label,
type: overrides.type,
required: overrides.required,
placeholder: null,
group: null,
order: existingParams.length + 1,
help_text: null,
options: null,
default: overrides.defaultValue,
validation: null,
sensitive: overrides.sensitive,
}
// Update both fields
setForm(f => ({
...f,
script_body: updatedScript,
parameters_schema: {
parameters: [...f.parameters_schema.parameters, newParam],
},
}))
setIsDirty(true)
}
const handleSkipCandidate = () => {
// Nothing to do — stepper advances internally
}
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
setShowStepper(false)
setDetectedCandidates([])
setDetectionSummary(
acceptedCount === 0
? 'No parameters were added.'
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
)
setTimeout(() => setDetectionSummary(null), 5000)
}
```
**Step 4: Add UI elements to the Script Body section**
In the JSX, find the Script Body section (around line 334-348). After the `<ScriptBodyEditor>` and before `</section>`, add the detect button and stepper:
```tsx
<ScriptBodyEditor
value={form.script_body}
onChange={v => updateField('script_body', v)}
/>
{/* Detect Parameters button + stepper */}
{form.script_body.trim() && !showStepper && (
<button
type="button"
onClick={handleDetectParameters}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
>
<Scan size={14} />
Detect Parameters
</button>
)}
{detectionSummary && (
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
)}
{showStepper && detectedCandidates.length > 0 && (
<ParameterDetectorStepper
candidates={detectedCandidates}
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
onAccept={handleAcceptCandidate}
onSkip={handleSkipCandidate}
onFinish={handleDetectionFinish}
/>
)}
```
**Step 5: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 6: Commit**
```bash
git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx
git commit -m "feat: wire parameter detection into ScriptTemplateEditor"
```
---
### Task 5: Manual testing checklist
**Step 1: Test with variable assignments**
1. Navigate to `/scripts/manage` → click New Template
2. Paste this script body:
```powershell
$ServerName = 'DC01'
$OUPath = 'OU=Users,DC=contoso,DC=com'
$DefaultPassword = 'Welcome123!'
$ForceChange = $true
$MaxRetries = 3
```
3. Click "Detect Parameters"
4. Verify 5 candidates appear in stepper
5. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number
6. Accept all — verify script body has `{{key}}` placeholders and Parameters section has 5 entries with defaults preserved
**Step 2: Test with param() block**
Paste:
```powershell
param(
[string]$ServerName = "DC01",
[switch]$WhatIf,
[SecureString]$AdminPassword,
[int]$Port = 443
)
$Connection = "https://$ServerName:$Port"
```
Expected: 4 candidates from param block (ServerName, WhatIf, AdminPassword, Port) + 0 from assignments (Connection is a complex expression, not a simple literal)
**Step 3: Test with function-level param (should be skipped)**
Paste:
```powershell
$GlobalPath = 'C:\Scripts'
function Load-Users {
param($filter = "*")
Get-ADUser -Filter $filter
}
```
Expected: 1 candidate only (GlobalPath). The function-level `param($filter)` should NOT appear.
**Step 4: Test edge cases**
- Empty script body → Detect Parameters button hidden
- Script with only `{{key}}` placeholders → "No parameter candidates detected"
- Script with PS internals like `$ErrorActionPreference = 'Stop'` → skipped
- Re-running detect after accepting some → already-converted values skipped
**Step 5: Commit any fixes**
```bash
git commit -m "fix: address issues found during parameter detector testing"
```
---
### Task 6: Final build verification and push
**Step 1: Run full build**
Run: `cd frontend && npm run build`
Expected: SUCCESS with no type errors
**Step 2: Push**
```bash
git push
```

View File

@@ -0,0 +1,703 @@
# Stack Priorities And Playwright Plan
> **Date:** 2026-03-16
> **Updated:** 2026-03-18
> **Product:** ResolutionFlow
> **Purpose:** Turn the recent stack-gap review into a practical, sequenced execution plan
## Completion Status
| Item | Status | Notes |
|------|--------|-------|
| Product analytics (PostHog) | ✅ Complete | All 9 events tracked, identifyUser/resetAnalytics wired to auth, PostHogProvider in main.tsx |
| Playwright e2e | ✅ Complete | 17 spec files, full CI job, auth storage state, both webServers managed in config |
| Better empty states | ✅ Complete | Illustrative empty states rolled out across 8 pages, upgraded EmptyState component with illustration + learn-more support, 2 new guide entries |
| Onboarding checklist | ✅ Complete | Backend status/dismiss endpoints, dashboard checklist widget with structured steps |
| Professional exports | ✅ Complete | PDF export via WeasyPrint with branded template, supporting data in all export formats, team branding CRUD + UI settings, supporting data capture CRUD + UI |
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled (no gate yet) |
| Security headers | ✅ Complete | HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP report-only |
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals library |
| Search and recall improvements | ✅ Complete | Structured filters (domain, confidence, ticket, date), PostgreSQL FTS with GIN index, Command Palette AI session search, Voyage AI semantic similar sessions |
| Evidence-rich sessions | ✅ Complete | Railway S3 storage service, file_uploads model, upload/download API, RichTextInput with clipboard paste, wired into FlowPilot (intake, free-text, escalation), evidence in exports |
| Smart PSA / client context | ⬜ Not started | |
| Queue / worker architecture | ⬜ Not started | |
| Buyer-facing trust surfaces | ⬜ Not started | |
---
---
## Summary
ResolutionFlow already has a credible application stack:
- React 19 + TypeScript + Vite on the frontend
- FastAPI + async SQLAlchemy + PostgreSQL on the backend
- Sentry on both frontend and backend
- CI with backend coverage plus frontend lint/test/build
- Strong backend integration test coverage
- Route-level lazy loading and bundle chunking already in place
The next step is not a stack rewrite.
The biggest gains now come from:
1. Better product visibility
2. Better release confidence
3. Better enterprise trust signals
4. Better workflow gravity inside the app
---
## Ranked Recommendations
### 1. Fastest Wins
These are the best short-term upgrades if the goal is to make the product feel more polished and more professional quickly.
#### 1. Product analytics instrumentation
**Why:** Sentry tells us when the app breaks. It does not tell us what users value, where they stall, or what converts.
**Recommended:** PostHog
**Track first:**
- Account created
- First successful login
- First flow viewed
- First session started
- First session completed
- First export generated
- First AI feature used
- First PSA integration connected
- First shared session created
**Why this is a fast win:** High leverage with low UI churn.
#### 2. Better empty states and onboarding guidance
**Why:** Mature apps reduce ambiguity. Empty libraries, empty analytics, and empty integrations pages should guide the next action immediately.
**Add first:**
- Empty flow library CTA
- Empty analytics explanation + “how data appears here”
- Empty integrations state with benefit-oriented copy
- New team “starter checklist”
#### 3. Professional exports
**Why:** Exports are one of the fastest ways for a B2B product to feel premium.
**Add first:**
- Client-ready PDF export
- Logo/header metadata block
- Cleaner ticket/session summary layout
- Optional evidence attachment section
#### 4. Coverage gates in CI
**Why:** Cheap trust signal internally. Prevents quality drift as the codebase expands.
**Add first:**
- Fail backend CI if total coverage drops below agreed threshold
- Publish frontend coverage report
---
### 2. Best ROI
These are the best medium-term investments if the goal is to improve product quality and roadmap clarity without taking on huge platform risk.
#### 1. Playwright end-to-end coverage
**Why:** Backend coverage is strong, but frontend confidence is still thinner than the apps complexity now deserves.
**High-value flows to cover first:**
- Login
- Authenticated app shell loads
- Session history loads
- Account settings save flow
- Feedback submission
- Shared session page access
**Why this is high ROI:** It catches real regressions users actually feel.
#### 2. Security header hardening
**Why:** MSP buyers care about security posture. This is both a real protection layer and a professionalism layer.
**Add first:**
- Content-Security-Policy
- Strict-Transport-Security
- X-Frame-Options or CSP `frame-ancestors`
- Referrer-Policy
- Permissions-Policy
- Trusted host validation where appropriate
#### 3. Web Vitals and performance budgeting
**Why:** Route splitting is already implemented, so the next step is protecting performance over time.
**Track first:**
- LCP
- INP
- CLS
- Initial JS size
- Editor route chunk size
- Landing page chunk size
#### 4. Search and recall improvements
**Why:** One of the biggest compounding opportunities is turning ResolutionFlow into team memory, not just a flow runner.
**Good first step:**
- Search for similar sessions and prior resolutions by flow, tag, client, or ticket context
---
### 3. Biggest Enterprise / Trust Upgrades
These are the moves most likely to change how serious buyers perceive the product.
#### 1. Evidence-rich sessions
**Add:**
- Screenshot upload/paste
- Attachments
- Command output capture
- Evidence in exports
**Why it matters:** MSP work is proof-heavy. Evidence makes the platform feel operationally complete.
#### 2. Smart PSA / client context
**Add:**
- Ticket details
- Client/site context
- Related recent sessions
- Asset/configuration context
- SLA metadata
**Why it matters:** This is what reduces alt-tabbing and makes ResolutionFlow feel indispensable.
#### 3. Queue / worker architecture
**Why:** AI tasks, indexing, imports, notifications, and integration syncs will eventually compete with request handling.
**Likely candidates:**
- AI generation jobs
- KB imports
- Embedding/indexing
- Webhook fan-out
- Scheduled maintenance orchestration
- PDF generation
#### 4. Buyer-facing trust surfaces
**Add:**
- Changelog
- Status page
- Security page
- Backup/export promise
- Clear onboarding docs
**Why it matters:** Buyers infer maturity from these before they inspect the product deeply.
---
## Recommended Execution Order
### Do Now
1. Add product analytics
2. Add Playwright for core journeys
3. Add security headers and trust hardening
4. Improve empty states and professional exports
### Do Next
1. Add smart PSA/client context in sessions
2. Add evidence-rich sessions and attachments
3. Add search/recall improvements
4. Add Web Vitals and performance budgets
### Explore After That
1. Add queue/worker architecture
2. Expand offline/PWA support for session running
3. Add deeper RMM context integrations
---
## Playwright Implementation Plan
## Goal
Add Playwright in a way that improves confidence quickly without creating a brittle, high-maintenance test suite.
The right strategy is:
- start with a small smoke suite
- prefer stable selectors and seeded users
- avoid highly dynamic AI/editor interactions in phase 1
- run Chromium first
- only expand once the suite is reliable in CI
---
## Why Playwright Fits This Stack
ResolutionFlow is a good Playwright candidate because:
- the frontend is a browser-heavy SPA
- route transitions and auth flows matter a lot
- many important regressions are UI integration issues, not backend unit issues
- CI already exists, so there is a natural place to add an e2e job
---
## Recommended Phase 1 Scope
Start with the least brittle, highest-signal journeys.
### Phase 1 tests
1. **Login smoke test**
- visit `/login`
- sign in with seeded test user
- verify redirect into authenticated app
2. **Authenticated shell loads**
- verify sidebar/nav renders
- verify key route content appears
3. **Session history page loads**
- navigate to `/sessions`
- verify tabs or session history shell renders
4. **Account settings save flow**
- navigate to `/account/profile` or `/account`
- edit a safe field if possible
- verify success toast/message
5. **Feedback form flow**
- navigate to `/feedback`
- submit a simple feedback entry
- verify success state
6. **Shared session public page**
- only if a reliable fixture exists
- otherwise defer to phase 2
### Avoid in Phase 1
- AI chat assertions
- Monaco-heavy editor interactions
- drag-and-drop editor behavior
- cross-reference graph assertions
- timing-sensitive maintenance flows
Those are better once the test harness is stable.
---
## Test User Strategy
Use your existing seeded local users from [seed_test_users.py](/home/michaelchihlas/dev/patherly/backend/scripts/seed_test_users.py).
### Existing seeded accounts
- `admin@resolutionflow.example.com`
- `pro@resolutionflow.example.com`
- `teamadmin@resolutionflow.example.com`
- `engineer@resolutionflow.example.com`
### Shared password
- `TestPass123!`
### Recommended test account for phase 1
Use `teamadmin@resolutionflow.example.com` for most authenticated tests.
Why:
- broad enough permissions
- less risky than binding all tests to super admin
- closer to realistic team usage
---
## How To Implement Playwright
## 1. Add dependencies
From `frontend/`:
```bash
npm install -D @playwright/test
npx playwright install chromium
```
Optional later:
```bash
npx playwright install
```
That installs Firefox/WebKit too, but Chromium is the right starting point.
---
## 2. Add package scripts
Add these scripts to [frontend/package.json](/home/michaelchihlas/dev/patherly/frontend/package.json):
```json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug"
}
}
```
---
## 3. Add Playwright config
Create [frontend/playwright.config.ts](/home/michaelchihlas/dev/patherly/frontend/playwright.config.ts).
Recommended shape:
```ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: 'npm run preview -- --host 127.0.0.1 --port 4173',
port: 4173,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
```
### Why `vite preview` instead of `vite dev`
Use the built app in e2e so tests are closer to production behavior and less vulnerable to dev-server quirks.
---
## 4. Add an e2e folder structure
Recommended:
```text
frontend/
e2e/
auth.spec.ts
navigation.spec.ts
feedback.spec.ts
fixtures/
auth.ts
utils/
session.ts
```
Keep helpers small. Avoid building a giant abstraction layer too early.
---
## 5. Add a login helper
Because the app stores tokens in `localStorage`, there are two valid strategies:
### Option A: Log in through the UI
Best for the first smoke test.
Pros:
- verifies the real login flow
- simple to understand
Cons:
- slower when repeated across many specs
### Option B: Log in through the API and set storage state
Best after the first smoke test works.
Pros:
- much faster
- reduces duplicated login steps across tests
Cons:
- does not itself verify the login form UI
### Recommended approach
- keep **one** UI login spec
- use **API login + saved storage state** for the rest
Because the backend already supports `POST /api/v1/auth/login/json`, Playwright can authenticate directly.
Example shape:
```ts
import { request, expect } from '@playwright/test'
export async function loginViaApi(baseApiUrl: string) {
const api = await request.newContext()
const response = await api.post(`${baseApiUrl}/api/v1/auth/login/json`, {
data: {
email: 'teamadmin@resolutionflow.example.com',
password: 'TestPass123!',
},
})
expect(response.ok()).toBeTruthy()
return response.json()
}
```
Then inject `access_token` and `refresh_token` into localStorage before page load.
---
## 6. Make selectors stable
Playwright is easiest to maintain when the UI exposes stable selectors.
The current UI already has some good `aria-label` coverage, which is enough for many tests. Where a flow is critical or copy is likely to change, add `data-testid`.
### Recommended `data-testid` targets
- login form
- login submit button
- app sidebar
- session history page shell
- feedback form
- save buttons on important settings pages
### Rule of thumb
- prefer `getByRole()` and `getByLabel()` first
- add `data-testid` for high-value flows where text is decorative or likely to change
---
## 7. Seed data before e2e runs
Phase 1 should not depend on manually-created accounts.
Recommended flow:
1. start Postgres
2. run backend migrations
3. run `python -m scripts.seed_test_users`
4. start backend
5. start frontend preview server
6. run Playwright
If later tests need trees, add a second seed step for flows:
```bash
python -m scripts.seed_trees
python -m scripts.seed_trees_v2
python -m scripts.seed_procedural_flows
```
For the very first phase, user-seeding alone is enough if tests stay focused on auth, navigation, feedback, and settings.
---
## 8. Add initial smoke specs
### `auth.spec.ts`
Covers:
- login page loads
- valid login succeeds
- invalid login shows error
### `navigation.spec.ts`
Covers:
- authenticated app shell renders
- `/sessions` loads
- `/feedback` loads
- `/account` loads
### `feedback.spec.ts`
Covers:
- feedback form submit
- success state visible
Keep these small. One assertion-heavy mega-test is worse than a few short focused tests.
---
## 9. Add Playwright to CI
Your existing CI workflow is already in [.github/workflows/ci.yml](/home/michaelchihlas/dev/patherly/.github/workflows/ci.yml). Add a separate `e2e` job instead of mixing Playwright into the existing frontend unit-test job.
### Recommended CI job shape
1. checkout
2. set up Python
3. set up Node
4. start Postgres service
5. install backend dependencies
6. install frontend dependencies
7. run migrations
8. seed test users
9. start backend in background
10. build frontend
11. install Playwright browser
12. run Playwright against `vite preview`
13. upload Playwright report/artifacts on failure
### Important detail
Set `VITE_API_URL=http://127.0.0.1:8000` for the frontend build used in CI e2e.
---
## Suggested CI Commands
Backend:
```bash
cd backend
alembic upgrade head
python -m scripts.seed_test_users
uvicorn app.main:app --host 127.0.0.1 --port 8000 &
```
Frontend:
```bash
cd frontend
npm ci
npm run build
npx playwright install --with-deps chromium
npm run test:e2e
```
Use environment variables:
```bash
VITE_API_URL=http://127.0.0.1:8000
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4173
```
---
## Phase 2 Expansion
Once the smoke suite is stable, expand into actual business-critical flows.
### Phase 2 candidates
1. Start and resume a session
2. Export a session
3. Create a share link
4. Open analytics pages
5. Validate account integrations page behavior
### Phase 2.5 candidates
1. Tree library filters
2. Fork flow flow
3. Step library browse/search
4. Public shared session experience
### Phase 3 candidates
1. Editor workflows
2. Procedural runner
3. Drag-and-drop interactions
4. AI-assisted workflows
Only bring editors and AI into Playwright once the harness is already trustworthy.
---
## Practical Advice For This Repo
### Keep Playwright separate from Vitest
Vitest should stay for:
- small component logic
- hooks
- utilities
- API client logic
Playwright should cover:
- auth
- routing
- critical user journeys
- integration behavior
### Dont try to test everything
You do not need Playwright coverage for every page. Cover the flows that:
- affect demos
- affect activation
- affect trust
- are expensive to break
### Start with one browser
Chromium first.
Only add Firefox/WebKit after the suite is stable and worth the extra runtime.
### Prefer reliable fixture creation over brittle UI setup
Use backend seeds and API helpers whenever possible.
---
## Recommended First PR For Playwright
Keep the first implementation intentionally small.
### Include
1. `@playwright/test` dependency
2. `playwright.config.ts`
3. e2e scripts in `package.json`
4. one UI login smoke test
5. one authenticated navigation smoke test
6. CI e2e job
### Do not include yet
- editor drag-and-drop tests
- AI flow tests
- PDF validation
- multi-browser matrix
- large helper framework
That first PR should prove the harness works end to end.
---
## Final Recommendation
If only one quality investment gets prioritized right now, it should be:
**Playwright + product analytics together**
Why:
- Playwright improves confidence in shipping
- analytics improves confidence in prioritizing
That combination is one of the cleanest ways to make ResolutionFlow feel more professional both internally and externally.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,885 @@
# FlowPilot-First Pivot — Phase 2: PSA Integration & Escalation Handoff
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Connect FlowPilot to ConnectWise PSA so engineers can start sessions from tickets, and documentation flows back automatically on resolution or escalation. Also implement escalation handoffs with full context briefing, session pause/resume for individual engineers, and in-app escalation notifications.
**Architecture:** Builds on existing PSA infrastructure (`services/psa/`, `PsaConnection` model, ConnectWise client) and Phase 1 AI session models (`AISession`, `AISessionStep`, `FlowPilotEngine`). Adds PSA ticket intake to sessions, auto-documentation push on close, session pause/resume, and escalation handoff mechanics.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), httpx (ConnectWise API), React, TypeScript, Tailwind CSS v4, shadcn/ui
**Prerequisites:**
- Phase 1 complete (AI session core — models, engine, API, frontend)
- Existing PSA integration (`docs/plans/2026-03-14-connectwise-psa-integration-plan.md`)
- Existing models: `PsaConnection`, `PsaMemberMapping`, `PsaPostLog`
- Existing services: `services/psa/base.py`, `services/psa/connectwise/client.py`, `services/psa/connectwise/provider.py`
- Existing service: `services/psa/ticket_context.py` — has `format_ticket_context_for_prompt()` already
- Existing schemas: `schemas/psa_context.py` — has `TicketContext`, `TicketDetails`, `CompanyInfo`, `ConfigItem`, `TicketNote`, etc.
- Existing frontend: `TicketPickerModal.tsx`, `TicketContextPanel.tsx`, `IntegrationsPage.tsx`
- Existing service: `services/redaction_service.py` — has `apply_redaction_to_text()` for password redaction
**Existing patterns to follow:**
- PSA: `app/services/psa/` — abstract `PSAProvider` interface + ConnectWise implementation
- PSA context: `app/services/psa/connectwise/provider.py``get_ticket_context()` already fetches ticket + company + contact + configs + notes + related tickets in parallel with caching
- PSA prompt formatting: `app/services/psa/ticket_context.py``format_ticket_context_for_prompt()` already formats `TicketContext` into structured text for AI prompts
- Sessions: `app/api/endpoints/sessions.py` — existing ticket linking patterns
- Phase 1: `app/services/flowpilot_engine.py`, `app/api/endpoints/ai_sessions.py`
- Frontend API pattern: `src/api/aiSessions.ts` uses `aiSessionsApi` object pattern (not standalone exports)
- Frontend ticket UI: `src/components/session/TicketPickerModal.tsx` (note: currently takes `sessionId` prop for old sessions — needs adapter)
---
## Key Design Decisions (from product review)
These decisions were confirmed during product review before implementation:
1. **PSA connection scope:** Per-account (one CW connection per MSP). Individual engineers mapped to CW members via `PsaMemberMapping`.
2. **CW API failure at intake:** Graceful degradation — engineer can manually type ticket number and paste ticket notes. Session starts without rich context. Ticket can be linked later to pull in contact/company details.
3. **Missing CW member mapping for time entries:** Show warning "Map your CW account in Settings to enable auto-logged time entries." Always include start time, end time, and total duration in the note text regardless.
4. **PSA push failure retry:** Automatic background retries via APScheduler (up to 3 attempts, exponential backoff). Plus a manual "Retry" button that only appears when auto-retries are exhausted or push is in failed state.
5. **Session ownership on escalation:** Engineer A **keeps ownership** (`session.user_id` unchanged). Session goes to `requesting_escalation` status. Engineer B works within the same session but A remains the originator. Both see it in their history.
6. **Escalation vs Pause:** Two separate features:
- **Pause/Resume** — same engineer, bookmark for later or recover from browser crash. Status: `paused`.
- **Escalation** — handoff to another engineer with context briefing. Status: `requesting_escalation``escalated` (when picked up). Self-escalation blocked.
7. **Escalation queue location:** Both — sidebar nav item "Escalations" with badge count AND a tab in session history page.
8. **Escalation pickup UX:** Engineer B sees a briefing card summarizing A's work, then chooses: (a) "Continue where they left off" (picks up same conversation), or (b) "Start fresh with context" (types their own input, but FlowPilot knows everything A tried so it won't repeat steps).
9. **Mid-session ticket linking:** Inject ticket context into system prompt immediately. FlowPilot naturally acknowledges the new context in its next response ("Thanks for linking that ticket. I can see this is for [client]...").
10. **Ticket status on resolve:** Contextual dropdown pulled dynamically from the linked ticket's board statuses (not a global setting). Admin setting just controls whether engineers are prompted to pick a status.
11. **Tests:** Mocked CW responses based on OpenAPI spec. No sandbox available yet.
---
## Context: What Phase 2 Adds
Phase 1 delivered FlowPilot with free-text intake only. Phase 2 makes it a ticket workflow tool:
**PSA Ticket Intake:** Engineer selects a ConnectWise ticket → FlowPilot pulls ticket data (summary, client, priority, history, configuration items) and uses it as rich context for diagnosis. If CW is unavailable, engineer can manually enter ticket number and paste notes — graceful degradation, not a hard block.
**Auto Documentation Push:** On resolution or escalation, FlowPilot auto-generates documentation and pushes it back to the ConnectWise ticket as internal notes + time entry. Automatic background retries on failure, with manual retry fallback. Notes always include session timing (start, end, duration) even if time entry creation fails due to missing member mapping.
**Session Pause/Resume:** Engineer can pause a session and come back later, or recover seamlessly from browser crashes and page reloads. Same engineer, same session.
**Escalation Handoff:** Engineer A hits a wall → clicks Escalate → FlowPilot packages everything tried so far → session goes to "requesting escalation" status → Engineer B sees it in the escalation queue (sidebar + session history tab) → picks it up with a briefing card → chooses to continue where A left off or start fresh with full context. Engineer A retains session ownership throughout. Self-escalation is blocked.
---
## Slice 1: PSA Ticket Intake for AI Sessions
### Task 1: Extend FlowPilotEngine to accept PSA ticket intake
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
**What to add:**
Add a new method `_process_ticket_intake()` that:
1. Receives `psa_connection_id` and `psa_ticket_id` from the intake request
2. Loads the `PsaConnection` from the database
3. **Attempts** to use `ConnectWiseProvider.get_ticket_context()` — if this fails (API down, bad credentials), catch the error and fall back gracefully (session starts with just the ticket ID stored, no rich context)
4. On success: stores the full ticket context (serialized `TicketContext`) in `session.ticket_data` JSONB
5. Uses the existing `format_ticket_context_for_prompt()` from `services/psa/ticket_context.py` to build the system prompt context block — do NOT rewrite this formatting, it already handles all fields correctly
6. Builds enriched intake content that includes both the formatted ticket context and any additional free-text the engineer provided
7. Passes the enriched context to `_classify_intake()` and the system prompt
**Graceful degradation on CW failure:**
If `get_ticket_context()` fails, the session still starts:
- `session.psa_ticket_id` is set (so we know which ticket to push docs to later)
- `session.psa_connection_id` is set
- `session.ticket_data` is null or minimal (just the ticket ID)
- The engineer's free-text intake (which may include pasted ticket notes) is used as the sole context
- A warning is returned in the response: `"psa_context_status": "unavailable"` so the frontend can show "Couldn't pull ticket details — ConnectWise may be unavailable"
**IMPORTANT — Reuse existing infrastructure:**
The ConnectWise provider at `services/psa/connectwise/provider.py` already has `get_ticket_context()` which returns a `TicketContext` schema (defined in `schemas/psa_context.py`). And `services/psa/ticket_context.py` already has `format_ticket_context_for_prompt()` that converts a `TicketContext` into a structured text block for AI prompts. Both of these are battle-tested from the existing session/copilot system. Phase 2 should call them directly:
```python
from app.services.psa.ticket_context import format_ticket_context_for_prompt
from app.services.psa.registry import get_provider_for_connection
# In _process_ticket_intake():
try:
provider = await get_provider_for_connection(psa_connection_id, db)
ticket_context = await provider.get_ticket_context(int(psa_ticket_id), str(psa_connection_id))
ticket_prompt_block = format_ticket_context_for_prompt(ticket_context)
session.ticket_data = ticket_context.model_dump(mode="json")
psa_context_status = "loaded"
except Exception as e:
logger.warning(f"Failed to fetch ticket context: {e}")
ticket_prompt_block = None
psa_context_status = "unavailable"
```
**Modify `start_session()`** to detect `intake_type == 'psa_ticket'` and call `_process_ticket_intake()` before the normal flow. The ticket context gets injected into the system prompt alongside any matched flow context.
**Key detail:** The engineer may also type additional context alongside the ticket pull (e.g., "Ticket #12345 — user called back and said it's also affecting their second monitor"). The intake content should merge both sources.
**Verification:** Start a session with `intake_type: "psa_ticket"` and a valid ticket ID. Verify FlowPilot's first question references the ticket content. Check `session.ticket_data` is populated. Also test with a bad connection — verify session still starts with a warning.
```
git commit -m "feat(ai-session): add PSA ticket intake to FlowPilot Engine"
```
### Task 2: Add ticket picker to FlowPilot intake screen
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotIntake.tsx`
**What to add:**
The "Pull from Ticket" button (currently disabled from Phase 1) becomes active when the user's account has a PSA connection configured.
**IMPORTANT — TicketPickerModal adaptation:** The existing `TicketPickerModal` at `src/components/session/TicketPickerModal.tsx` was built for legacy sessions — it requires a `sessionId` prop and calls `sessionPsaApi.linkTicket()` internally. For the FlowPilot intake screen, you need to either:
- (a) Create a new `FlowPilotTicketPicker` component that reuses the search/display logic but returns the selected ticket data to the parent instead of calling the link API, or
- (b) Refactor `TicketPickerModal` to accept an `onSelect` callback prop as an alternative to `sessionId`, making it usable in both contexts
Option (b) is preferred since it avoids code duplication. Add an `onSelect?: (ticketId: string, ticket: PSATicketInfo) => void` prop. When provided, the modal calls `onSelect` instead of the internal link API. The existing legacy usage passes `sessionId` + `onLinked` as before (no breaking change).
On click, open the adapted `TicketPickerModal`. When a ticket is selected:
1. The ticket summary populates the intake area as a styled ticket card (showing ticket #, summary, client name, priority badge)
2. An additional textarea appears below for "Add context" — optional free text the engineer can add
3. The intake type switches to `psa_ticket` (or `combined` if they also add text)
4. On submit, `createAISession()` is called with `intake_type: "psa_ticket"`, `psa_ticket_id`, `psa_connection_id`, and `intake_content` containing both the ticket reference and any additional text
**Manual ticket entry fallback:** If the ticket picker fails to connect to CW, or the engineer prefers, they can also manually type a ticket number and paste relevant notes into the free-text area. This still sets `intake_type: "psa_ticket"` with the ticket number, but `psa_connection_id` triggers a context fetch attempt on the backend (which may gracefully fail per Task 1).
**UX details:**
- Check for active PSA connections via existing `useTicketContext` hook or the integrations API
- If no PSA connection exists, the "Pull from Ticket" button shows a tooltip: "Connect your PSA in Settings → Integrations"
- The ticket card should match the existing `TicketContextPanel` styling — dark glass card with cyan accent border, ticket number prominent
- After ticket selection, "Start Session" button text changes to "Start Session with Ticket #12345"
- If CW fetch fails, show toast: "Couldn't reach ConnectWise — you can still type the ticket details manually"
**Verification:** Open the FlowPilot intake. Click "Pull from Ticket". Search for a ticket in ConnectWise. Select it. See the ticket card appear. Add optional context. Submit. Verify the session starts with ticket data.
```
git commit -m "feat(ai-session): add PSA ticket picker to FlowPilot intake"
```
### Task 3: Display ticket context in active session sidebar
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Create: `frontend/src/components/flowpilot/SessionTicketCard.tsx`
**What to add:**
When the active session has `psa_ticket_id` set, show a `SessionTicketCard` in the right sidebar above the confidence indicator. This card shows:
- Ticket # (clickable — opens ConnectWise ticket in new tab if URL is available)
- Ticket summary
- Client name
- Priority badge (color-coded)
- Status badge
- Config items list (if any)
If `ticket_data` is minimal (CW was unavailable at intake), show a simplified card with just the ticket number and a "Refresh from CW" button that attempts to pull context again.
Reuse styling patterns from the existing `TicketContextPanel` and `TicketLinkIndicator` components.
**Verification:** Start a ticket-based session. See the ticket card in the sidebar with all relevant info.
```
git commit -m "feat(ai-session): display ticket context in FlowPilot session sidebar"
```
---
## Slice 2: Auto Documentation Push to PSA
### Task 4: Build PSA documentation push service
**Files:**
- Create: `backend/app/services/psa_documentation_service.py`
**Architecture:**
This service takes a completed `AISession` and pushes structured documentation back to ConnectWise. It handles three operations:
1. **Internal Note:** Full diagnostic trail posted as an internal note on the ticket
2. **Time Entry:** Auto-create a time entry with the session duration (if CW member mapping exists)
3. **Status Update:** Optionally update ticket status based on contextual selection at resolution time
**Internal note format:**
```
═══ FlowPilot Session Documentation ═══
Session: {session_id}
Engineer: {user.display_name}
Date: {resolved_at}
Started: {created_at}
Ended: {resolved_at}
Duration: {duration_display}
── Problem ──
{problem_summary}
Domain: {problem_domain}
── Diagnosis Path ──
1. [Question] {context_message}
→ Response: {selected_option or free_text_input}
2. [Action] {content description}
→ Result: {action_result summary}
3. [Question] {context_message}
→ Response: {selected_option}
... (all steps)
── Resolution ──
{resolution_summary}
{resolution_action}
── AI Confidence ──
Final confidence: {confidence_tier} ({confidence_score})
Matched flow: {matched_flow_name or "None - new discovery"}
── Session Timing ──
Start: {created_at formatted}
End: {resolved_at formatted}
Total: {duration_display}
Generated by ResolutionFlow FlowPilot
```
**IMPORTANT — Always include timing:** The "Session Timing" section is always present in the note, even when a time entry can't be created (missing member mapping). This ensures the time data is always on the ticket for manual entry.
**For escalations, the format changes:**
```
═══ FlowPilot Escalation Documentation ═══
Session: {session_id}
Escalated by: {user.display_name}
Escalated to: {escalated_to.display_name or "Unassigned"}
Date: {resolved_at}
Started: {created_at}
Duration: {duration_display}
── Problem ──
{problem_summary}
── Work Completed ──
{numbered list of all steps taken}
── Escalation Reason ──
{escalation_reason}
── Remaining Hypotheses ──
{from escalation_package.hypotheses}
── Suggested Next Steps ──
{from escalation_package.suggestions}
── Session Timing ──
Start: {created_at formatted}
Escalated: {escalated_at formatted}
Total: {duration_display}
Generated by ResolutionFlow FlowPilot
```
**Key implementation details:**
- Use the existing PSA provider abstraction (`services/psa/base.py``post_note()`)
- **PsaPostLog FK issue:** The existing `PsaPostLog` model has `ForeignKey("sessions.id")` pointing to old sessions, NOT `ai_sessions`. You must add an `ai_session_id` nullable UUID FK column to `PsaPostLog` (via migration) so it can reference AI sessions. Keep the original `session_id` column for backward compatibility — make it nullable if it isn't already.
- **Time entry method missing:** The PSA base class and ConnectWise provider do NOT currently have a `create_time_entry()` method. You must add: (1) `async def create_time_entry(ticket_id, member_id, hours, notes, work_type)` to `services/psa/base.py` as an abstract method, (2) implement it in `services/psa/connectwise/provider.py` using the CW `POST /time/entries` endpoint, (3) add a `PSATimeEntry` type to `services/psa/types.py`
- **Missing member mapping handling:** Before creating a time entry, look up the engineer's CW member ID via `PsaMemberMapping`. If no mapping exists: skip the time entry, include a `member_mapping_warning` in the response ("Map your CW account in Settings → Integrations to enable auto-logged time entries"). The note text always includes timing regardless.
- Use `apply_redaction_to_text()` from `services/redaction_service.py` to scrub passwords and sensitive data before pushing to ConnectWise
- Time entry calculation: `session.resolved_at - session.created_at`, rounded to nearest 15 minutes (configurable via `flowpilot_settings`)
- **Automatic retry on failure:** If the PSA push fails, create a `PsaPostLog` entry with `status='pending_retry'`. APScheduler job runs every 5 minutes, retries failed pushes up to 3 times with exponential backoff (5min, 15min, 45min). After 3 failures, status becomes `failed` and the frontend shows a manual "Retry" button.
- The documentation text should be plain text (ConnectWise notes don't support markdown well)
**Verification:** Resolve an AI session that has a linked ticket. Check ConnectWise — verify the internal note appeared on the ticket with the full diagnostic trail including timing. Verify a time entry was created (if member mapped). Check `psa_post_logs` table for the audit record.
```
git commit -m "feat(ai-session): add PSA documentation push service"
```
### Task 5: Wire documentation push into session resolution/escalation + background retry
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Create: `backend/app/services/psa_retry_scheduler.py`
**What to add:**
In the `resolve_session()` and `escalate_session()` methods, after generating documentation, check if the session has a `psa_ticket_id` and `psa_connection_id`. If so, call `psa_documentation_service.push_documentation()`.
**Flow:**
1. Engineer clicks Resolve → `POST /ai-sessions/{id}/resolve`
2. `flowpilot_engine.resolve_session()` generates documentation (existing)
3. **New:** If session has PSA link, call `psa_documentation_service.push_documentation(session, documentation)`
4. Push runs async — don't block the response
5. Return `SessionCloseResponse` with new fields: `psa_push_status`, `member_mapping_warning`
Same flow for escalation.
**Background retry scheduler (`psa_retry_scheduler.py`):**
APScheduler job that runs every 5 minutes:
1. Query `PsaPostLog` for entries with `status='pending_retry'` and `retry_count < 3`
2. For each, attempt the push again via `psa_documentation_service`
3. On success: update `status='sent'`
4. On failure: increment `retry_count`, set next retry with exponential backoff
5. After 3 failures: set `status='failed'`
Register the scheduler in the FastAPI lifespan (follows existing APScheduler pattern for maintenance flows).
**Add to response schemas:**
Edit `backend/app/schemas/ai_session.py` — add PSA fields to `SessionCloseResponse`:
```python
class SessionCloseResponse(BaseModel):
session_id: UUID
status: str
documentation: SessionDocumentation
psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed
psa_push_error: str | None = None
member_mapping_warning: str | None = None # Set when time entry skipped due to missing mapping
```
**Add manual retry endpoint:**
```
POST /api/v1/ai-sessions/{id}/retry-psa-push
```
Only callable when the session's latest `PsaPostLog` entry has `status='failed'`. Resets to `pending_retry` and triggers an immediate push attempt.
**Verification:** Resolve a ticket-linked session. Verify the response includes `psa_push_status: "sent"`. Check ConnectWise for the note. Resolve a session without a ticket — verify `psa_push_status: "no_psa"`. Test with a user who has no CW member mapping — verify `member_mapping_warning` is present and note still includes timing.
```
git commit -m "feat(ai-session): wire PSA documentation push into resolve/escalate with auto-retry"
```
### Task 6: Show PSA push status in frontend
**Files:**
- Edit: `frontend/src/components/flowpilot/SessionDocView.tsx`
- Edit: `frontend/src/types/ai-session.ts`
**What to add:**
After resolution/escalation, the documentation view now shows a PSA sync indicator:
- **"sent":** Green checkmark + "Documentation pushed to ticket #{ticket_id}"
- **"pending_retry":** Amber clock icon + "Documentation queued for push — will sync shortly"
- **"failed":** Red warning + "Failed to push to ticket — {error}" with a "Retry" button that calls `POST /ai-sessions/{id}/retry-psa-push`
- **"no_psa":** No indicator shown (session wasn't linked to a ticket)
If `member_mapping_warning` is present, show an info banner: "Time entry was not created — [Map your CW account](link to settings) to enable auto-logged time. Session timing is included in the ticket note."
Update the TypeScript types to include `psa_push_status`, `psa_push_error`, and `member_mapping_warning` on `SessionCloseResponse`.
**Verification:** Resolve a ticket-linked session. See "Documentation pushed to ticket #12345" in the documentation view. Test retry button with a simulated failure.
```
git commit -m "feat(ai-session): show PSA push status in documentation view"
```
---
## Slice 3: Session Pause/Resume & Escalation Handoff
### Task 7: Session pause/resume for same engineer
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/schemas/ai_session.py`
**What to add:**
Engineers need to pause a session and come back later (lunch break, waiting for info, browser crash recovery).
**New endpoint — Pause session:**
```
POST /api/v1/ai-sessions/{id}/pause
```
Flow:
1. Verify session is `active` and belongs to current user
2. Set `session.status = "paused"`, `session.paused_at = utcnow()`
3. Return updated session
**New endpoint — Resume own paused session:**
```
POST /api/v1/ai-sessions/{id}/resume
```
Flow:
1. Verify session is `paused` and belongs to current user
2. Set `session.status = "active"`, clear `paused_at`
3. Return the session with all existing steps (engineer picks up exactly where they left off)
4. No briefing step needed — it's the same engineer
**Browser crash recovery:**
Sessions in `active` status should be resumable by navigating back to `/pilot/{sessionId}`. The frontend should detect an existing active session and restore it (conversation history is already in `conversation_messages` JSONB). This is mostly a frontend concern — the backend already stores all state.
**Verification:** Start a session, progress 3 steps. Pause it. Navigate away. Come back. Resume. Verify you're back at step 3 with full context. Also test: close the browser tab while in an active session, reopen, navigate to session — verify it loads correctly.
```
git commit -m "feat(ai-session): add session pause/resume for same engineer"
```
### Task 8: Build escalation handoff backend
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/schemas/ai_session.py`
**Session status lifecycle (updated from Phase 1):**
```
active → paused (same engineer pause)
paused → active (same engineer resume)
active → requesting_escalation (engineer requests escalation)
requesting_escalation → active (another engineer picks it up)
active → resolved (session completed)
active → escalated (escalation completed — terminal, session was handed off and resolved by another engineer)
requesting_escalation → escalated (escalation expired or cancelled — terminal)
```
**Key ownership rule:** `session.user_id` ALWAYS stays as Engineer A (the originator). When Engineer B picks up the session, we track them via a new `current_handler_id` field (or in the `escalation_package` JSONB). Both engineers see the session in their history — A sees "I escalated this" and B sees "I picked this up."
**Modify the existing `escalate_session()` in `flowpilot_engine.py`:**
1. Change `session.status = "escalated"``session.status = "requesting_escalation"`
2. Do NOT set `session.resolved_at` yet (session isn't done — it's waiting for pickup)
3. Store `session.escalation_package["original_user_id"] = str(user_id)`
4. **Block self-escalation:** If `escalated_to_id == current_user.id`, return 400 error
**Enhance `_build_escalation_package()`:**
The existing Phase 1 implementation builds a basic package with `problem_summary`, `steps_tried`, and `escalation_reason`. Enhance it to also include:
- `remaining_hypotheses`: Make a quick LLM call (haiku-tier via `AI_MODEL_TIERS["fast"]`) asking: "Based on this diagnostic conversation, what are the most likely remaining causes that haven't been ruled out?" Pass the conversation_messages as context.
- `suggested_next_steps`: From the same LLM call: "What should the next engineer try first?"
- `steps_ruled_out`: Walk the steps and identify options that were tested and failed
- `environment_context`: Extract any environment-specific info mentioned during the session (server names, IP addresses, software versions, etc.)
- `original_user_id`: The engineer who escalated (for attribution in the briefing)
This LLM call should use the fast model since it's a summarization task, not a diagnostic one. If the call fails, fall back to the basic package without hypotheses/suggestions — don't block the escalation.
**New endpoint — Pick up escalated session:**
```
POST /api/v1/ai-sessions/{id}/pickup
```
Request body:
```python
class PickupSessionRequest(BaseModel):
"""Pick up an escalated session as a new engineer."""
resume_mode: str = "continue" # "continue" or "fresh"
additional_context: str | None = None # New info or question from the receiving engineer
```
**Pickup flow:**
1. Verify session status is `requesting_escalation`
2. Verify the current user has permission (same team) and is NOT the original engineer
3. Track the new handler (add to `escalation_package["picked_up_by"] = str(user_id)`, `escalation_package["picked_up_at"] = utcnow()`)
4. Set `session.status = "active"`
5. Generate a "briefing step" — a special step that summarizes everything for the new engineer:
- "Here's what {original_engineer} found so far: ..."
- "They ruled out X, Y, Z"
- "Remaining hypotheses: A, B"
- "Suggested next steps: ..."
6. Based on `resume_mode`:
- `"continue"`: Generate the next diagnostic step as usual (picks up where A left off)
- `"fresh"`: Use `additional_context` as new input, but FlowPilot's system prompt includes all of A's work so it won't repeat steps
7. Return the briefing step + next step
**New endpoint — List sessions requesting escalation for team:**
```
GET /api/v1/ai-sessions/escalation-queue
```
Returns sessions with `status = "requesting_escalation"` for the current user's team, sorted by most recent. This is the "pickup queue" for escalated tickets. Includes: problem summary, escalation reason, who escalated, when, ticket # (if linked), step count, assigned-to (if specified).
**Verification:** Start a session, progress 3-4 steps, escalate with a reason. Verify session is `requesting_escalation`. Log in as another user on the same team. Hit `/ai-sessions/escalation-queue`. See the session. Pick it up with `resume_mode: "continue"`. Verify the briefing step accurately summarizes prior work. Continue diagnosis. Also test `resume_mode: "fresh"` with additional context.
```
git commit -m "feat(ai-session): add escalation handoff backend with pickup flow"
```
### Task 9: Escalation handoff frontend + in-app notifications
**Files:**
- Create: `frontend/src/components/flowpilot/EscalateModal.tsx`
- Create: `frontend/src/components/flowpilot/EscalationQueue.tsx`
- Create: `frontend/src/components/flowpilot/SessionBriefing.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotActionBar.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Edit: `frontend/src/hooks/useFlowPilotSession.ts`
- Edit: `frontend/src/api/aiSessions.ts`
- Edit: `frontend/src/types/ai-session.ts`
- Edit: `frontend/src/components/layout/Sidebar.tsx` (or equivalent nav component)
- Edit: `frontend/src/router.tsx`
**EscalateModal:**
When the engineer clicks "Escalate" in the action bar, this modal opens:
- Textarea: "Why are you escalating?" (required)
- Dropdown: "Assign to" — list of team members (optional, defaults to unassigned)
- Summary card: auto-generated preview of the escalation package (steps taken, hypotheses remaining)
- "Escalate & Update Ticket" button (if PSA linked) / "Escalate" button (if not)
- **Self-escalation blocked:** Current user excluded from the "Assign to" dropdown
**EscalationQueue:**
New component accessible from **both** the sidebar nav and as a tab in session history.
**Sidebar nav item:** "Escalations" with a badge showing count of sessions in `requesting_escalation` status for the user's team. Badge uses amber-400 color. Positioned below "Sessions" in the nav.
**Session history tab:** New tab "Escalated" alongside existing tabs. Shows the same queue content.
Queue content:
- Card for each session in `requesting_escalation`: problem summary, escalation reason, who escalated, when, ticket # (if linked), step count
- "Pick Up" button on each card
- Sort by most recent
- Filter by: assigned to me, unassigned, all
**SessionBriefing:**
When an engineer picks up an escalated session, the first thing they see is a styled briefing card (distinct from normal step cards — use an amber/purple accent border to distinguish from regular cyan steps):
- "Escalation from {original_engineer}"
- Problem summary
- Steps already taken (collapsed list, expandable)
- What was ruled out
- Remaining hypotheses
- Suggested next steps
- Two action buttons:
- **"Continue Where They Left Off"** → calls pickup with `resume_mode: "continue"`, proceeds to FlowPilot's next question
- **"Start Fresh With Context"** → shows a textarea for the engineer to type their own input/question, then calls pickup with `resume_mode: "fresh"` and `additional_context`
**Pause/Resume UI (from Task 7):**
- Add "Pause" button to `FlowPilotActionBar` (alongside Resolve and Escalate)
- Paused sessions show in session history with a "Paused" badge
- Clicking a paused session resumes it automatically (or shows a "Resume" button)
- On page load, if navigating to `/pilot/{sessionId}` and session is `active`, restore the full conversation (browser crash recovery)
**API client additions:**
Add to the existing `aiSessionsApi` object in `src/api/aiSessions.ts` (follow the same pattern as existing methods):
```typescript
// Add to aiSessionsApi object:
async pauseSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/pause`
)
return response.data
},
async resumeSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/resume`
)
return response.data
},
async pickupSession(sessionId: string, data: { resume_mode: string; additional_context?: string }): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,
data
)
return response.data
},
async getEscalationQueue(): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data
},
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/link-ticket`,
data
)
return response.data
},
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string }> {
const response = await apiClient.post<{ psa_push_status: string }>(
`/ai-sessions/${sessionId}/retry-psa-push`
)
return response.data
},
```
**Hook updates:**
Add `pauseSession`, `resumeSession`, `pickupSession`, `escalationQueue`, `linkTicket`, `retryPsaPush` to `useFlowPilotSession`.
**Router updates:**
- Add route for the escalation queue page (e.g., `/escalations`)
- Ensure `/pilot/{sessionId}` handles all session states (active, paused, requesting_escalation)
**Verification:** Full escalation flow — Engineer A starts session, progresses, escalates with reason. Engineer B sees it in the sidebar queue (badge count), picks it up via "Continue Where They Left Off", sees the briefing, continues diagnosis, resolves. Also test: Engineer B picks up via "Start Fresh With Context" with their own input. Also test pause/resume for same engineer.
```
git commit -m "feat(ai-session): add escalation handoff and pause/resume frontend"
```
---
## Slice 4: Session-to-Ticket Linking for Existing Sessions
### Task 10: Link an in-progress session to a ticket retroactively
**Files:**
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
**What to add:**
Sometimes an engineer starts a session with free-text intake and then realizes "oh, this is ticket #12345." They should be able to link a ticket mid-session.
**New endpoint:**
```
POST /api/v1/ai-sessions/{id}/link-ticket
```
Request:
```python
class LinkTicketRequest(BaseModel):
psa_ticket_id: str
psa_connection_id: UUID
```
**Flow:**
1. Fetch ticket data from ConnectWise (graceful failure — if CW is down, still store the ticket ID for later doc push)
2. Update `session.psa_ticket_id`, `session.psa_connection_id`, `session.ticket_data`
3. **Inject ticket context into FlowPilot's system prompt** for subsequent steps — append the formatted ticket context to `session.conversation_messages` system prompt. FlowPilot will naturally acknowledge the new context in its next response.
4. Return updated session data
**Frontend:**
Add a "Link Ticket" button in the session sidebar (where the ticket card would be, if there isn't one). Opens the adapted `TicketPickerModal` (with `onSelect` prop from Task 2). On selection, calls `linkTicket()` and the `SessionTicketCard` appears in the sidebar.
**Verification:** Start a free-text session. Progress a few steps. Click "Link Ticket". Select a ticket. Verify ticket card appears in sidebar. Continue diagnosis — verify FlowPilot's next response acknowledges the ticket context. Resolve. Verify documentation pushes to the linked ticket.
```
git commit -m "feat(ai-session): add mid-session ticket linking with context injection"
```
---
## Slice 5: Configuration & Settings
### Task 11: FlowPilot PSA settings
**Files:**
- Edit: `frontend/src/pages/account/IntegrationsPage.tsx` (or create a new section)
- Edit: `backend/app/models/psa_connection.py` (add fields if needed)
- Edit: `backend/app/api/endpoints/integrations.py` (settings CRUD)
**What to add:**
Under the existing PSA integrations settings, add a "FlowPilot Settings" section:
- **Auto-push documentation:** Toggle (default: on) — automatically push session documentation to linked tickets on resolution
- **Auto-create time entry:** Toggle (default: on) — automatically create a time entry when resolving (requires CW member mapping)
- **Time rounding:** Dropdown — "Nearest 15 minutes" (default), "Nearest 30 minutes", "Exact", "Don't create time entries"
- **Default note visibility:** Dropdown — "Internal only" (default), "Internal and external"
- **Include diagnostic steps in notes:** Toggle (default: on) — if off, only push the summary, not the full step trail
- **Prompt for ticket status on resolution:** Toggle (default: off) — when on, engineer sees a status dropdown at resolution time, populated dynamically from the linked ticket's board statuses via `get_ticket_statuses(board_id)`. When off, ticket status is not changed.
- **Prompt for ticket status on escalation:** Toggle (default: off) — same as above but for escalation
**Note on status dropdowns:** These are NOT global dropdowns in settings. The setting is just a toggle for whether the engineer is prompted. The actual status options are pulled dynamically at resolution/escalation time based on the specific ticket's board (using the existing `get_ticket_statuses(board_id)` method). This is board-agnostic — works correctly regardless of which CW board the ticket is on.
These settings should be stored on the `PsaConnection` model as a `flowpilot_settings` JSONB column (add via migration if needed).
**Verification:** Navigate to integrations settings. See FlowPilot settings section. Toggle settings. Resolve a session with "prompt for status" enabled — verify the status dropdown shows the correct statuses for that ticket's board. Verify the documentation push respects all configured settings.
```
git commit -m "feat(ai-session): add FlowPilot PSA configuration settings"
```
---
## Summary of All New/Modified Files
### Backend — New
```
app/services/psa_documentation_service.py # Documentation push to PSA
app/services/psa_retry_scheduler.py # APScheduler job for retrying failed PSA pushes
```
### Backend — Modified
```
app/services/flowpilot_engine.py # PSA ticket intake, pause/resume, enhanced escalation, pickup
app/api/endpoints/ai_sessions.py # Pause, resume, pickup, escalation queue, link-ticket, retry-push endpoints
app/schemas/ai_session.py # New schemas: PickupSessionRequest, LinkTicketRequest, psa_push_status, member_mapping_warning
app/models/psa_connection.py # Add flowpilot_settings JSONB column
app/models/psa_post_log.py # Add ai_session_id FK, make session_id nullable, add retry_count
app/services/psa/base.py # Add abstract create_time_entry() method
app/services/psa/types.py # Add PSATimeEntry type
app/services/psa/connectwise/provider.py # Implement create_time_entry() for CW API
app/components/session/TicketPickerModal.tsx # Add onSelect callback prop for dual-mode usage
alembic/versions/xxx_phase2_psa_flowpilot.py # Migration: flowpilot_settings, psa_post_log changes
```
### Frontend — New
```
src/components/flowpilot/EscalateModal.tsx # Enhanced escalation dialog with team member dropdown
src/components/flowpilot/EscalationQueue.tsx # Pickup queue for escalated sessions
src/components/flowpilot/SessionBriefing.tsx # Handoff briefing card with continue/fresh options
src/components/flowpilot/SessionTicketCard.tsx # Ticket info in session sidebar
```
### Frontend — Modified
```
src/components/flowpilot/FlowPilotIntake.tsx # Ticket picker integration, manual fallback, PSA connection check
src/components/flowpilot/FlowPilotSession.tsx # Ticket card in sidebar, link ticket button, pause/resume
src/components/flowpilot/FlowPilotActionBar.tsx # Pause button, escalate opens enhanced modal
src/components/flowpilot/SessionDocView.tsx # PSA push status indicator, retry button, member mapping warning
src/components/session/TicketPickerModal.tsx # Add onSelect prop for FlowPilot intake usage
src/components/layout/Sidebar.tsx # Escalation queue nav item with badge count
src/hooks/useFlowPilotSession.ts # Pause, resume, pickup, linkTicket, escalationQueue, retryPsaPush
src/api/aiSessions.ts # New API functions (follow aiSessionsApi object pattern)
src/types/ai-session.ts # New types: psa_push_status, PickupSessionRequest, etc.
src/pages/account/IntegrationsPage.tsx # FlowPilot PSA settings section
src/router.tsx # Escalation queue route
```
---
## Database Changes
**Migration:** This phase requires a single migration with multiple changes:
```python
# 1. Add flowpilot_settings to psa_connections
op.add_column('psa_connections', sa.Column(
'flowpilot_settings',
sa.dialects.postgresql.JSONB(),
nullable=True,
server_default='{}',
comment='FlowPilot-specific settings: auto_push, time_rounding, note_visibility, etc.'
))
# 2. Add ai_session_id FK to psa_post_log (existing table points to old sessions only)
op.add_column('psa_post_log', sa.Column(
'ai_session_id',
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey('ai_sessions.id', ondelete='CASCADE'),
nullable=True,
comment='FK to AI sessions (Phase 2). Original session_id FK remains for legacy sessions.'
))
op.create_index('ix_psa_post_log_ai_session_id', 'psa_post_log', ['ai_session_id'])
# 3. Make original session_id nullable (was NOT NULL — legacy sessions only)
op.alter_column('psa_post_log', 'session_id', nullable=True)
# 4. Add retry_count to psa_post_log for automatic retries
op.add_column('psa_post_log', sa.Column(
'retry_count',
sa.Integer(),
nullable=False,
server_default='0',
comment='Number of retry attempts for failed PSA pushes'
))
op.add_column('psa_post_log', sa.Column(
'next_retry_at',
sa.DateTime(timezone=True),
nullable=True,
comment='When to attempt the next retry'
))
```
**Also update `PsaPostLog` model** (`app/models/psa_post_log.py`): Add the `ai_session_id` mapped column and relationship. Make `session_id` `Optional`. Add `retry_count` and `next_retry_at`.
**Also update `PsaConnection` model** (`app/models/psa_connection.py`): Add the `flowpilot_settings` JSONB mapped column.
**Also update PSA abstraction layer:**
- `services/psa/types.py`: Add `PSATimeEntry` model
- `services/psa/base.py`: Add abstract `create_time_entry()` method
- `services/psa/connectwise/provider.py`: Implement `create_time_entry()` using CW `POST /time/entries`
**Run migration:**
```bash
cd /projects/patherly/backend
DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \
venv/bin/alembic upgrade head
```
---
## Testing Strategy
All tests use mocked ConnectWise responses based on the OpenAPI spec (no CW sandbox available yet). Mock shapes should match `docs/connectwise/connectwise-psa-resolutionflow-reference.json`.
### Backend Unit Tests
**Files:** `backend/tests/test_psa_documentation_service.py`
- Test documentation formatting for resolved sessions (verify timing section always present)
- Test documentation formatting for escalated sessions
- Test password redaction in documentation
- Test time entry calculation (rounding logic for 15min, 30min, exact)
- Test PSA push with mock ConnectWise client
- Test missing member mapping — verify warning returned and note still includes timing
- Test retry logic — verify exponential backoff scheduling
**Files:** `backend/tests/test_escalation_handoff.py`
- Test escalation package generation (including LLM-generated hypotheses)
- Test self-escalation blocked (400 error)
- Test session pickup flow — "continue" mode (new engineer, briefing step)
- Test session pickup flow — "fresh" mode (new engineer provides own context)
- Test ownership preserved (session.user_id stays as Engineer A)
- Test permission enforcement (can't pick up session from another team)
- Test pause/resume for same engineer
**Files:** `backend/tests/test_ai_sessions_psa.py`
- Full flow: create ticket-based session → diagnose → resolve → verify PSA push with timing
- Full flow: create session → escalate → pickup by another user → resolve
- Test mid-session ticket linking with context injection
- Test PSA push failure → automatic retry → eventual success
- Test PSA push failure → exhaust retries → manual retry button
- Test graceful degradation when CW API is unavailable at intake
### Frontend Manual Testing
1. Start a session from a ticket — verify FlowPilot references ticket context
2. Start a session with CW unavailable — verify manual fallback works
3. Resolve a ticket session — verify ConnectWise shows the note with timing
4. Resolve without CW member mapping — verify warning shown, timing in notes
5. Start a free-text session → link ticket mid-session → verify FlowPilot acknowledges context → resolve → verify push
6. Pause a session → navigate away → come back → resume → verify full context preserved
7. Close browser tab during active session → reopen → navigate to session → verify recovery
8. Full escalation: Engineer A escalates → Engineer B sees badge in sidebar → picks up via "Continue" → sees briefing → resolves
9. Full escalation: Engineer B picks up via "Start Fresh" with own context → FlowPilot doesn't repeat A's steps
10. Verify Engineer A still sees the session in their history after B picks it up
11. Test PSA settings — toggle options, verify behavior changes
12. Test ticket status prompt at resolution — verify correct statuses shown for that ticket's board
---
## What Comes Next (Phase 3 — NOT in scope here)
For context only — do NOT implement these in Phase 2:
- **Knowledge Flywheel:** Post-session flow proposal generation
- **Review Queue:** UI for approving AI-generated flow proposals
- **Flow Editor as curation tool:** Repurpose for reviewing AI-generated flows
- **In-session Script Generator:** FlowPilot invokes script generation contextually
- **Knowledge gap detection:** Track free-text escapes, high escalation categories
- **Team analytics:** MTTR, resolution rates, knowledge coverage
- **Escalation notifications:** Push notifications or email alerts for escalation queue (Phase 2 has in-app badge only)

View File

@@ -0,0 +1,116 @@
# Security Headers, Coverage Gates & Web Vitals Design
> **Date:** 2026-03-18
> **Product:** ResolutionFlow
> **Branch:** `feat/security-headers-coverage-performance`
> **Purpose:** Add HTTP security headers, enforce test coverage gates, and instrument Core Web Vitals reporting
---
## Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Security headers priority | First | Highest trust signal for MSP buyers, real protection |
| CSP rollout strategy | Report-only first, then enforce | Avoids breaking third-party integrations (PostHog, Sentry, Google Fonts, React Flow) |
| Backend coverage gate | 80% fail threshold | Already near this level, prevents drift |
| Frontend coverage gate | Report-only (no gate yet) | Starting from zero — establish baseline first |
| Web Vitals destination | PostHog | 100% of sessions captured (vs Sentry's 20% sample), correlate with product analytics |
---
## 1. Security Headers Middleware
### New file: `backend/app/core/security_headers.py`
Starlette middleware that adds security headers to every response.
### Headers
| Header | Value | Purpose |
|--------|-------|---------|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Force HTTPS for 1 year |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing |
| `X-Frame-Options` | `DENY` | Block iframe embedding |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limit referrer leakage |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Disable unused browser APIs |
| `Content-Security-Policy-Report-Only` | *(see below)* | CSP in report-only mode |
### CSP Directive (report-only)
```
default-src 'self';
script-src 'self' https://us.i.posthog.com https://*.sentry.io;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: blob:;
connect-src 'self' https://us.posthog.com https://us.i.posthog.com https://*.sentry.io https://api.resolutionflow.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
```
### Wiring
- Add middleware in `main.py` after CORS middleware (so CORS preflight responses aren't affected)
- CSP directives configurable via `config.py` so promotion to enforcing mode is a config change, not a code change
- HSTS only sent when `DEBUG=false` (avoid locking localhost into HTTPS)
### Tests
Integration test that hits an endpoint and asserts all expected headers are present with correct values.
---
## 2. Coverage Gates
### Backend: Enforce at 80%
- Add `--cov-fail-under=80` to the pytest command in CI
- One-line change — reporting already wired up with `pytest-cov`
### Frontend: Report-only (establish baseline)
- Install `@vitest/coverage-v8` as dev dependency
- Add coverage config to `vite.config.ts`:
- Reporters: `text` + `json-summary` + `html`
- Include: `src/**/*.{ts,tsx}`
- Exclude: `src/test/`, `src/types/`, `**/*.d.ts`
- Add `test:coverage` script to `package.json`
- Update CI to run `npm run test:coverage` instead of `npm test`
- Display summary in CI output — no failure threshold yet
- Add `coverage/` to `.gitignore`
---
## 3. Web Vitals → PostHog
### Install
`web-vitals` npm package.
### New file: `frontend/src/lib/webVitals.ts`
- Import `onLCP`, `onINP`, `onCLS`, `onFCP`, `onTTFB` from `web-vitals`
- Each callback sends a PostHog event (`web_vitals`) with properties:
- `metric_name` — LCP, INP, CLS, FCP, TTFB
- `metric_value` — numeric value
- `metric_rating` — good / needs-improvement / poor
- `page_path` — current route
- Single `initWebVitals()` function that registers all observers
### Wiring
Call `initWebVitals()` in `main.tsx` after PostHog initialization.
---
## Scope Summary
| Area | Scope | Files |
|------|-------|-------|
| Security headers | New middleware + config + test | 3-4 backend files |
| Coverage gates | CI config + vitest coverage setup | CI workflow + 3 frontend config files |
| Web Vitals | New lib + dependency + main.tsx wiring | 2-3 frontend files |
Small, contained changes across all three. No architectural changes or new database models.

View File

@@ -0,0 +1,466 @@
# Security Headers, Coverage Gates & Web Vitals Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add HTTP security headers to every response, enforce backend test coverage at 80%, add frontend coverage reporting, and instrument Core Web Vitals via PostHog.
**Architecture:** Three independent workstreams — (1) new Starlette middleware for security headers with configurable CSP, (2) CI pipeline updates for coverage gates, (3) new frontend lib for web-vitals → PostHog. No database changes, no new API endpoints.
**Tech Stack:** FastAPI/Starlette middleware (Python), pytest-cov (backend coverage), @vitest/coverage-v8 (frontend coverage), web-vitals + posthog-js (frontend performance)
---
## Task 1: Security Headers Middleware
**Files:**
- Create: `backend/app/core/security_headers.py`
- Modify: `backend/app/core/config.py:49` (add CSP config settings)
- Modify: `backend/app/main.py:236` (register middleware after CORS)
### Step 1: Write the failing test
Create `backend/tests/test_security_headers.py`:
```python
"""Tests for security headers middleware."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_security_headers_present(client: AsyncClient):
"""Every response should include security headers."""
response = await client.get("/health")
assert response.status_code == 200
# Non-CSP headers always present
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin"
assert "camera=()" in response.headers["permissions-policy"]
assert "microphone=()" in response.headers["permissions-policy"]
assert "geolocation=()" in response.headers["permissions-policy"]
@pytest.mark.asyncio
async def test_csp_report_only_header(client: AsyncClient):
"""CSP should be in report-only mode."""
response = await client.get("/health")
assert response.status_code == 200
csp = response.headers.get("content-security-policy-report-only")
assert csp is not None
assert "default-src 'self'" in csp
assert "script-src 'self'" in csp
assert "style-src 'self' 'unsafe-inline'" in csp
assert "frame-ancestors 'none'" in csp
@pytest.mark.asyncio
async def test_hsts_only_in_production(client: AsyncClient):
"""HSTS should NOT be sent when DEBUG=true (test environment)."""
response = await client.get("/health")
assert response.status_code == 200
assert "strict-transport-security" not in response.headers
```
### Step 2: Run test to verify it fails
```bash
cd backend && python -m pytest tests/test_security_headers.py -v
```
Expected: FAIL — headers not present yet.
### Step 3: Add CSP config to settings
Modify `backend/app/core/config.py`. Add after the `BCRYPT_ROUNDS` line (line 50):
```python
# Security Headers
CSP_REPORT_ONLY: bool = True # Set False to enforce CSP
CSP_EXTRA_SCRIPT_SOURCES: list[str] = [] # Additional script-src domains
CSP_EXTRA_CONNECT_SOURCES: list[str] = [] # Additional connect-src domains
```
### Step 4: Write the middleware
Create `backend/app/core/security_headers.py`:
```python
"""
Security headers middleware.
Adds standard security headers to every HTTP response:
- HSTS (production only)
- X-Content-Type-Options
- X-Frame-Options
- Referrer-Policy
- Permissions-Policy
- Content-Security-Policy (report-only by default)
"""
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from typing import Callable
from app.core.config import settings
def _build_csp_directive() -> str:
"""Build the CSP directive string from config."""
script_sources = "'self' https://us.i.posthog.com https://*.sentry.io"
if settings.CSP_EXTRA_SCRIPT_SOURCES:
script_sources += " " + " ".join(settings.CSP_EXTRA_SCRIPT_SOURCES)
connect_sources = (
"'self' https://us.posthog.com https://us.i.posthog.com "
"https://*.sentry.io https://api.resolutionflow.com"
)
if settings.CSP_EXTRA_CONNECT_SOURCES:
connect_sources += " " + " ".join(settings.CSP_EXTRA_CONNECT_SOURCES)
return "; ".join([
"default-src 'self'",
f"script-src {script_sources}",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob:",
f"connect-src {connect_sources}",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
])
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to every response."""
def __init__(self, app):
super().__init__(app)
# Pre-build CSP at startup so we don't rebuild per-request
self._csp = _build_csp_directive()
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
# Always set these headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=()"
)
# HSTS only in production (avoid locking localhost into HTTPS)
if not settings.DEBUG:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
# CSP — report-only or enforcing based on config
csp_header = (
"Content-Security-Policy-Report-Only"
if settings.CSP_REPORT_ONLY
else "Content-Security-Policy"
)
response.headers[csp_header] = self._csp
return response
```
### Step 5: Register middleware in main.py
Modify `backend/app/main.py`. Add import at top with other core imports (after line 30):
```python
from app.core.security_headers import SecurityHeadersMiddleware
```
Add middleware registration after the CORS block (after line 235, before `# Include API router`):
```python
# Add security headers middleware (after CORS so preflight responses work)
app.add_middleware(SecurityHeadersMiddleware)
```
### Step 6: Run tests to verify they pass
```bash
cd backend && python -m pytest tests/test_security_headers.py -v
```
Expected: 3 tests PASS.
### Step 7: Run full backend test suite
```bash
cd backend && python -m pytest --override-ini="addopts=" -v
```
Expected: All tests pass (security headers don't break existing tests).
### Step 8: Commit
```bash
git add backend/app/core/security_headers.py backend/app/core/config.py backend/app/main.py backend/tests/test_security_headers.py
git commit -m "feat: add security headers middleware with report-only CSP
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Backend Coverage Gate (80%)
**Files:**
- Modify: `.github/workflows/ci.yml:51` (add --cov-fail-under=80)
### Step 1: Update the pytest CI command
Modify `.github/workflows/ci.yml` line 51. Change:
```yaml
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json
```
To:
```yaml
- name: Run tests with coverage
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=80
```
### Step 2: Verify locally that coverage is above 80%
```bash
cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-fail-under=80
```
Expected: PASS with total coverage >= 80%.
### Step 3: Commit
```bash
git add .github/workflows/ci.yml
git commit -m "ci: enforce 80% backend coverage gate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Frontend Coverage Reporting
**Files:**
- Modify: `frontend/package.json` (add test:coverage script)
- Modify: `frontend/vite.config.ts:31-36` (add coverage config)
- Modify: `.github/workflows/ci.yml:93` (change npm test → npm run test:coverage)
- Modify: `.gitignore` (add coverage/)
### Step 1: Install @vitest/coverage-v8
```bash
cd frontend && npm install -D @vitest/coverage-v8
```
### Step 2: Add coverage config to vite.config.ts
Modify `frontend/vite.config.ts`. Replace the `test` block (lines 31-36) with:
```typescript
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/test/**',
'src/types/**',
'src/**/*.d.ts',
'src/instrument.ts',
'src/main.tsx',
],
},
},
```
### Step 3: Add test:coverage script to package.json
Modify `frontend/package.json`. Add after the `"test"` script (line 14):
```json
"test:coverage": "vitest run --coverage",
```
### Step 4: Add coverage/ to .gitignore
Modify `.gitignore`. Add after the `frontend/e2e/.auth/` line (line 221):
```
frontend/coverage/
```
### Step 5: Update CI to use test:coverage
Modify `.github/workflows/ci.yml` line 93. Change:
```yaml
- name: Test
run: cd frontend && npm test
```
To:
```yaml
- name: Test with coverage
run: cd frontend && npm run test:coverage
```
### Step 6: Verify locally
```bash
cd frontend && npm run test:coverage
```
Expected: Tests pass and coverage summary prints to terminal. Note the baseline percentage.
### Step 7: Commit
```bash
git add frontend/package.json frontend/package-lock.json frontend/vite.config.ts .github/workflows/ci.yml .gitignore
git commit -m "ci: add frontend coverage reporting via vitest/v8
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Web Vitals → PostHog
**Files:**
- Create: `frontend/src/lib/webVitals.ts`
- Modify: `frontend/src/main.tsx:23` (call initWebVitals after PostHog init)
### Step 1: Install web-vitals
```bash
cd frontend && npm install web-vitals
```
### Step 2: Create the web vitals module
Create `frontend/src/lib/webVitals.ts`:
```typescript
/**
* Core Web Vitals reporting via PostHog.
*
* Tracks LCP, INP, CLS, FCP, and TTFB as PostHog events so we can
* build dashboards and correlate performance with product usage.
*
* Each metric fires once per page load. PostHog captures 100% of
* sessions (vs Sentry's 20% sample), giving better coverage.
*/
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'
import type { Metric } from 'web-vitals'
import posthog from 'posthog-js'
function sendToPostHog(metric: Metric) {
// Only send if PostHog is loaded
if (!(posthog as unknown as { __loaded?: boolean }).__loaded) return
posthog.capture('web_vitals', {
metric_name: metric.name,
metric_value: metric.value,
metric_rating: metric.rating,
metric_delta: metric.delta,
metric_id: metric.id,
page_path: window.location.pathname,
})
}
/** Register all Web Vitals observers. Call once after PostHog init. */
export function initWebVitals() {
onLCP(sendToPostHog)
onINP(sendToPostHog)
onCLS(sendToPostHog)
onFCP(sendToPostHog)
onTTFB(sendToPostHog)
}
```
### Step 3: Wire into main.tsx
Modify `frontend/src/main.tsx`. Add import after the PostHog import (after line 8):
```typescript
import { initWebVitals } from './lib/webVitals'
```
Add the init call after the PostHog init block (after line 23, before `createRoot`):
```typescript
// Start Web Vitals reporting to PostHog
initWebVitals()
```
### Step 4: Verify the build
```bash
cd frontend && npm run build
```
Expected: Build succeeds with no errors.
### Step 5: Commit
```bash
git add frontend/src/lib/webVitals.ts frontend/src/main.tsx frontend/package.json frontend/package-lock.json
git commit -m "feat: add Core Web Vitals reporting to PostHog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Update stack priorities plan and verify
**Files:**
- Modify: `docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md` (update completion status)
### Step 1: Update the completion status table
In `docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md`, update these rows:
```markdown
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled (no gate yet) |
| Security headers | ✅ Complete | HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP report-only |
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals library |
```
### Step 2: Run the full test suite one more time
```bash
cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-fail-under=80
cd frontend && npm run test:coverage
cd frontend && npm run build
```
Expected: All pass.
### Step 3: Commit
```bash
git add docs/plans/2026-03-16-stack-priorities-and-playwright-plan.md
git commit -m "docs: mark security headers, coverage gates, and web vitals complete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
# Phase 5: Analytics Enhancement — Design Document
> **Date:** 2026-03-19
> **Status:** Approved
> **Audience:** Team leads (primary), individual engineers (secondary)
> **Future note:** A dedicated `/insights` dashboard is planned for a later phase — keep analytics queries modular for reuse.
---
## Overview
Extend the existing FlowPilot Analytics page with tabbed sections for coverage analysis, flow quality scoring, and PSA metrics. This completes the pivot architecture's Phase 5 ("Analytics + Polish") and provides the data team leads need to justify ROI and identify knowledge gaps.
## Page Structure
Add 4 tab sections to `FlowPilotAnalyticsPage`. Each tab fetches data lazily (only when selected).
| Tab | Content | Data Source |
|-----|---------|-------------|
| **Overview** | Existing metrics (sessions, resolution rate, MTTR, rating, confidence tiers, domain breakdown) | Existing `GET /analytics/flowpilot` |
| **Coverage** | Domain grid heatmap, domain-to-flow mapping, knowledge gap summary | New `GET /analytics/flowpilot/coverage` |
| **Flow Quality** | Flow scoring table, top/bottom performers, needs-attention badges | New `GET /analytics/flowpilot/flow-quality` |
| **PSA** | Time entries, hours tracked, push success funnel, trend chart | Extended existing endpoint + new tracking |
---
## Backend
### New Endpoint: Coverage Data
`GET /analytics/flowpilot/coverage?period=7d|30d|90d`
Response:
```json
{
"domains": [
{
"domain": "Active Directory",
"flow_count": 12,
"session_count": 87,
"resolution_rate": 0.92,
"escalation_rate": 0.05,
"guided_rate": 0.78,
"avg_resolution_minutes": 12.5
}
],
"unmapped_session_count": 15,
"total_domains": 8
}
```
Domain-to-flow mapping: match flows to domains via their category (categories already exist on trees). Sessions have `problem_domain`. Cross-reference to compute coverage per domain.
### New Endpoint: Flow Quality Data
`GET /analytics/flowpilot/flow-quality?period=7d|30d|90d&sort=quality|usage|success_rate`
Response:
```json
{
"flows": [
{
"flow_id": "uuid",
"name": "AD Password Reset",
"usage_count": 45,
"success_rate": 0.91,
"last_matched_at": "2026-03-18T...",
"avg_confidence": 0.82,
"quality_score": 0.87
}
],
"top_performers": [...],
"needs_attention": [...]
}
```
Quality score formula: `(success_rate * 0.5) + (guided_rate * 0.3) + (recency_score * 0.2)` where recency_score decays from 1.0 (used today) to 0.0 (not used in 90+ days).
### Flow Model Additions
Add to `trees` table:
- `usage_count` (Integer, default 0) — incremented when a session matches this flow
- `success_rate` (Float, nullable) — recalculated periodically by analytics query
- `last_matched_at` (DateTime(timezone=True), nullable) — updated on flow match
### PSA Activity Tracking
Add `psa_activity_log` table:
- `id` (UUID PK)
- `account_id` (UUID FK)
- `session_id` (UUID FK, nullable)
- `activity_type` (String) — "time_entry_posted", "note_posted", "status_updated"
- `hours_logged` (Float, nullable) — for time entries
- `psa_ticket_id` (String)
- `created_at` (DateTime)
Wire into the ConnectWise provider: when a time entry or note is successfully pushed, log to this table.
Extended PSA analytics response:
```json
{
"total_time_entries": 142,
"total_hours_logged": 356.5,
"avg_hours_per_session": 2.51,
"push_funnel": {
"total_sessions": 500,
"linked_to_ticket": 380,
"doc_pushed": 350,
"time_entry_logged": 142
},
"daily_trend": [
{"date": "2026-03-18", "entries": 8, "hours": 19.5}
]
}
```
---
## Frontend
### Coverage Tab — Domain Grid Heatmap
A table with colored cells:
| Domain | Flows | Sessions | Resolution % | Escalation % | Guided % |
|--------|-------|----------|-------------|-------------|---------|
| Active Directory | 12 | 87 | 92% (green) | 5% (green) | 78% (green) |
| Microsoft 365 | 3 | 45 | 58% (red) | 32% (red) | 28% (red) |
Cell coloring:
- Resolution rate: `bg-emerald-400/10` (>75%), `bg-amber-400/10` (50-75%), `bg-rose-500/10` (<50%)
- Escalation rate: green (<10%), amber (10-25%), red (>25%)
- Guided rate: green (>60%), amber (30-60%), red (<30%)
- Flow count: green (5+), amber (1-4), red (0)
Domains with red cells show "Create Flow" suggestion.
### Flow Quality Tab — Sortable Table
`.glass-card-static` table:
- Columns: Flow Name, Usage, Success Rate, Last Used, Avg Confidence, Quality Score
- Top 5 highlighted in emerald, bottom 5 in rose
- "Needs attention" badge: success_rate < 50% or unused 30+ days
- Click flow name → navigate to editor
### PSA Tab
- Metric cards: total entries, total hours, avg hours/session
- Push success funnel: horizontal funnel visualization with conversion rates
- Trend chart (Recharts area chart): daily time entries and hours
---
## Testing
Backend:
- Coverage endpoint returns correct domain mapping
- Flow quality scoring formula produces expected results
- PSA activity logging works on push
- All endpoints team_admin gated
Frontend:
- Tab switching loads correct data
- Heatmap cells colored correctly
- Flow quality table sorts correctly
- PSA funnel displays conversion rates

View File

@@ -0,0 +1,592 @@
# Phase 5: Analytics Enhancement — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Extend the FlowPilot Analytics page with tabbed sections for coverage heatmap, flow quality scoring, and PSA time tracking metrics.
**Architecture:** Add two new backend endpoints (`/coverage` and `/flow-quality`) alongside the existing `/analytics/flowpilot` endpoint. Add a `psa_activity_log` table for time entry tracking. Add flow usage columns to `trees`. Refactor the frontend analytics page into a tabbed layout with lazy-loaded sections.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), React 19, TypeScript, Tailwind CSS v4, Recharts
**Design doc:** `docs/plans/2026-03-19-phase5-analytics-enhancement-design.md`
---
## Task 1: Database — add flow tracking columns + PSA activity log table
**Files:**
- Modify: `backend/app/models/tree.py`
- Create: `backend/app/models/psa_activity_log.py`
- Create: migration file
**Step 1: Add flow tracking columns to Tree model**
In `backend/app/models/tree.py`, add after `gallery_sort_order`:
```python
# Flow quality tracking (Phase 5)
usage_count: Mapped[int] = mapped_column(Integer, default=0)
success_rate: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
last_matched_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
```
Import `Float` from sqlalchemy if not already imported.
**Step 2: Create PSA activity log model**
Create `backend/app/models/psa_activity_log.py`:
```python
"""PSA activity log — tracks time entries, note posts, and status updates pushed to PSA."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Float
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PsaActivityLog(Base):
__tablename__ = "psa_activity_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True
)
activity_type: Mapped[str] = mapped_column(String(50), nullable=False) # "time_entry_posted", "note_posted", "status_updated"
hours_logged: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
```
**Step 3: Register model in imports**
Ensure the model is importable. Check how other models are registered (e.g., in `backend/app/models/__init__.py` if it exists, or via alembic's `env.py`).
**Step 4: Generate and run migration**
```bash
cd /projects/patherly/backend && alembic revision --autogenerate -m "add flow tracking columns and psa_activity_logs table"
alembic upgrade head
```
**Step 5: Commit**
```bash
git commit -m "feat(analytics): add flow tracking columns and psa_activity_logs table"
```
---
## Task 2: Backend — coverage endpoint
**Files:**
- Modify: `backend/app/api/endpoints/flowpilot_analytics.py`
- Modify: `backend/app/schemas/flowpilot_analytics.py`
- Create: `backend/tests/test_analytics_coverage.py`
**Step 1: Add schemas**
In `backend/app/schemas/flowpilot_analytics.py`, add:
```python
class CoverageDomainRow(BaseModel):
domain: str
flow_count: int
session_count: int
resolution_rate: float
escalation_rate: float
guided_rate: float
avg_resolution_minutes: float | None = None
class CoverageResponse(BaseModel):
domains: list[CoverageDomainRow]
unmapped_session_count: int
total_domains: int
```
**Step 2: Add endpoint**
In `backend/app/api/endpoints/flowpilot_analytics.py`, add:
```python
@router.get("/coverage", response_model=CoverageResponse)
@limiter.limit("15/minute")
async def get_coverage(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_team_admin),
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
):
"""Coverage heatmap data — per-domain metrics for flow coverage analysis."""
if not current_user.account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account")
account_id = current_user.account_id
period_start = _get_period_start(period)
# Get all domains from sessions in period
domain_stats = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("session_count"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
func.sum(case((AISession.status == "escalated", 1), else_=0)).label("escalated"),
func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
)
.group_by(AISession.problem_domain)
)
domain_rows = domain_rows_result = domain_stats.all()
# Count flows per domain via category name matching
# Trees have category_id → Category.name, sessions have problem_domain
# Match by comparing Category.name to problem_domain
from app.models.category import Category
flow_counts_result = await db.execute(
select(Category.name, func.count(Tree.id))
.join(Tree, Tree.category_id == Category.id)
.where(Tree.account_id == account_id, Tree.is_active == True)
.group_by(Category.name)
)
flow_counts = {row[0]: row[1] for row in flow_counts_result.all()}
# Count unmapped sessions (no problem_domain)
unmapped_result = await db.execute(
select(func.count(AISession.id))
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.is_(None),
)
)
unmapped_count = unmapped_result.scalar() or 0
# Build response
domains = []
for row in domain_rows:
total = int(row.session_count)
resolved = int(row.resolved)
escalated = int(row.escalated)
guided = int(row.guided)
domain_name = row.problem_domain
domains.append(CoverageDomainRow(
domain=domain_name,
flow_count=flow_counts.get(domain_name, 0),
session_count=total,
resolution_rate=round(resolved / total, 3) if total else 0,
escalation_rate=round(escalated / total, 3) if total else 0,
guided_rate=round(guided / total, 3) if total else 0,
))
# Sort by session count descending
domains.sort(key=lambda d: d.session_count, reverse=True)
return CoverageResponse(
domains=domains,
unmapped_session_count=unmapped_count,
total_domains=len(domains),
)
```
**Step 3: Write tests**
Create `backend/tests/test_analytics_coverage.py` testing:
- Endpoint requires team_admin auth
- Returns domain breakdown with correct counts
- Unmapped sessions counted
- Empty state returns empty list
**Step 4: Run tests and verify**
```bash
cd /projects/patherly/backend && python -m pytest tests/test_analytics_coverage.py -v --override-ini="addopts="
```
**Step 5: Commit**
```bash
git commit -m "feat(analytics): add coverage heatmap endpoint"
```
---
## Task 3: Backend — flow quality endpoint
**Files:**
- Modify: `backend/app/api/endpoints/flowpilot_analytics.py`
- Modify: `backend/app/schemas/flowpilot_analytics.py`
- Create: `backend/tests/test_analytics_flow_quality.py`
**Step 1: Add schemas**
```python
class FlowQualityRow(BaseModel):
flow_id: str
name: str
tree_type: str
usage_count: int
success_rate: float | None = None
last_matched_at: datetime | None = None
avg_confidence: float | None = None
quality_score: float
class FlowQualityResponse(BaseModel):
flows: list[FlowQualityRow]
top_performers: list[FlowQualityRow]
needs_attention: list[FlowQualityRow]
```
**Step 2: Add endpoint**
```python
@router.get("/flow-quality", response_model=FlowQualityResponse)
@limiter.limit("15/minute")
async def get_flow_quality(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_team_admin),
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
sort: str = Query("quality", pattern="^(quality|usage|success_rate)$"),
):
```
Query logic:
- Get all active flows for the account
- For each flow, count sessions where `matched_flow_id == flow.id` in period
- Calculate success_rate = resolved / total matched
- Calculate quality_score = `(success_rate * 0.5) + (guided_rate * 0.3) + (recency_score * 0.2)`
- Recency score: 1.0 if used today, decays linearly to 0.0 at 90 days
- Top performers: top 5 by quality_score
- Needs attention: flows with success_rate < 0.5 or not used in 30+ days
**Step 3: Write tests, run, commit**
```bash
git commit -m "feat(analytics): add flow quality scoring endpoint"
```
---
## Task 4: Backend — PSA activity logging + enhanced PSA metrics
**Files:**
- Modify: `backend/app/services/psa/connectwise/provider.py` (or wherever note/time entry posting happens)
- Modify: `backend/app/api/endpoints/flowpilot_analytics.py`
- Modify: `backend/app/schemas/flowpilot_analytics.py`
**Step 1: Add PSA activity logging**
Find where the ConnectWise provider posts notes and time entries. After a successful push, log to `psa_activity_logs`:
```python
from app.models.psa_activity_log import PsaActivityLog
activity = PsaActivityLog(
account_id=account_id,
session_id=session_id,
activity_type="note_posted", # or "time_entry_posted"
hours_logged=hours, # for time entries
psa_ticket_id=ticket_id,
)
db.add(activity)
await db.commit()
```
**Step 2: Add enhanced PSA schemas**
```python
class PsaFunnel(BaseModel):
total_sessions: int
linked_to_ticket: int
doc_pushed: int
time_entry_logged: int
class PsaDailyTrend(BaseModel):
date: str
entries: int
hours: float
class EnhancedPsaMetrics(BaseModel):
total_time_entries: int
total_hours_logged: float
avg_hours_per_session: float
push_funnel: PsaFunnel
daily_trend: list[PsaDailyTrend]
```
**Step 3: Add PSA metrics endpoint**
```python
@router.get("/psa-metrics", response_model=EnhancedPsaMetrics)
```
Query `psa_activity_logs` and `ai_sessions` to build the funnel and trend data.
**Step 4: Write tests, run, commit**
```bash
git commit -m "feat(analytics): add PSA activity logging and enhanced PSA metrics endpoint"
```
---
## Task 5: Backend — wire flow matching stats
**Files:**
- Modify: `backend/app/services/flowpilot_engine.py` (or wherever flow matching happens)
**Step 1: Update flow stats on match**
Find where `matched_flow_id` is set on an `AISession`. At that point, also update the matched flow:
```python
# When a flow is matched to a session:
flow.usage_count = (flow.usage_count or 0) + 1
flow.last_matched_at = datetime.now(timezone.utc)
```
**Step 2: Update success_rate on resolution**
When a session resolves and has a `matched_flow_id`, recalculate that flow's success_rate:
```python
# After session resolves:
if session.matched_flow_id:
total = await db.execute(
select(func.count(AISession.id))
.where(AISession.matched_flow_id == session.matched_flow_id)
)
resolved = await db.execute(
select(func.count(AISession.id))
.where(AISession.matched_flow_id == session.matched_flow_id, AISession.status == "resolved")
)
flow.success_rate = round(resolved.scalar() / total.scalar(), 3) if total.scalar() else None
```
**Step 3: Commit**
```bash
git commit -m "feat(analytics): wire flow usage tracking into session matching and resolution"
```
---
## Task 6: Frontend — types and API client updates
**Files:**
- Modify: `frontend/src/types/flowpilot-analytics.ts`
- Modify: `frontend/src/api/flowpilotAnalytics.ts`
**Step 1: Add types**
```typescript
// Coverage
export interface CoverageDomainRow {
domain: string
flow_count: number
session_count: number
resolution_rate: number
escalation_rate: number
guided_rate: number
avg_resolution_minutes: number | null
}
export interface CoverageResponse {
domains: CoverageDomainRow[]
unmapped_session_count: number
total_domains: number
}
// Flow Quality
export interface FlowQualityRow {
flow_id: string
name: string
tree_type: string
usage_count: number
success_rate: number | null
last_matched_at: string | null
avg_confidence: number | null
quality_score: number
}
export interface FlowQualityResponse {
flows: FlowQualityRow[]
top_performers: FlowQualityRow[]
needs_attention: FlowQualityRow[]
}
// Enhanced PSA
export interface PsaFunnel {
total_sessions: number
linked_to_ticket: number
doc_pushed: number
time_entry_logged: number
}
export interface PsaDailyTrend {
date: string
entries: number
hours: number
}
export interface EnhancedPsaMetrics {
total_time_entries: number
total_hours_logged: number
avg_hours_per_session: number
push_funnel: PsaFunnel
daily_trend: PsaDailyTrend[]
}
```
**Step 2: Add API methods**
```typescript
async getCoverage(period: string = '30d'): Promise<CoverageResponse> {
const response = await apiClient.get<CoverageResponse>('/analytics/flowpilot/coverage', { params: { period } })
return response.data
},
async getFlowQuality(period: string = '30d', sort: string = 'quality'): Promise<FlowQualityResponse> {
const response = await apiClient.get<FlowQualityResponse>('/analytics/flowpilot/flow-quality', { params: { period, sort } })
return response.data
},
async getPsaMetrics(period: string = '30d'): Promise<EnhancedPsaMetrics> {
const response = await apiClient.get<EnhancedPsaMetrics>('/analytics/flowpilot/psa-metrics', { params: { period } })
return response.data
},
```
**Step 3: Run build, commit**
```bash
cd /projects/patherly/frontend && npm run build
git commit -m "feat(analytics): add coverage, flow quality, and PSA metrics types and API client"
```
---
## Task 7: Frontend — tabbed layout + Coverage heatmap tab
**Files:**
- Modify: `frontend/src/pages/FlowPilotAnalyticsPage.tsx`
- Create: `frontend/src/components/analytics/CoverageHeatmap.tsx`
**Step 1: Refactor page into tabs**
Add tab state and tab bar to `FlowPilotAnalyticsPage.tsx`:
- Tabs: "Overview", "Coverage", "Flow Quality", "PSA"
- Active tab: `bg-primary/10 text-foreground border-b-2 border-primary`
- Inactive: `text-muted-foreground hover:text-foreground`
- Move existing dashboard content into the Overview tab
- Each non-overview tab fetches its data lazily on first selection
**Step 2: Build CoverageHeatmap component**
`frontend/src/components/analytics/CoverageHeatmap.tsx`:
- `.glass-card-static` table container
- Table headers: Domain, Flows, Sessions, Resolution %, Escalation %, Guided %
- Cell coloring functions:
- Resolution: `bg-emerald-400/10 text-emerald-400` (>75%), `bg-amber-400/10 text-amber-400` (50-75%), `bg-rose-500/10 text-rose-500` (<50%)
- Escalation: green (<10%), amber (10-25%), red (>25%)
- Guided: green (>60%), amber (30-60%), red (<30%)
- Flows: green (5+), amber (1-4), red (0)
- Domains with 0 flows show "Create Flow" link
- Responsive: horizontal scroll on mobile (`overflow-x-auto`)
**Step 3: Run build, commit**
```bash
git commit -m "feat(analytics): add tabbed layout and coverage heatmap"
```
---
## Task 8: Frontend — Flow Quality tab
**Files:**
- Create: `frontend/src/components/analytics/FlowQualityTable.tsx`
- Modify: `frontend/src/pages/FlowPilotAnalyticsPage.tsx`
**Step 1: Build FlowQualityTable component**
- `.glass-card-static` sortable table
- Columns: Flow Name (link to editor), Usage, Success Rate, Last Used, Avg Confidence, Quality Score
- Column headers clickable to sort
- Top 5 rows: left border `border-l-2 border-emerald-400`
- Bottom 5 rows: left border `border-l-2 border-rose-500`
- "Needs attention" badge (`bg-amber-400/10 text-amber-400 font-label text-[0.625rem]`) on flows with success_rate < 50% or unused 30+ days
- Quality score displayed as a colored bar (0-100% width, emerald/amber/rose)
- Click flow name → navigate to `/trees/{id}/edit`
**Step 2: Wire into tab, run build, commit**
```bash
git commit -m "feat(analytics): add flow quality scoring table"
```
---
## Task 9: Frontend — PSA metrics tab
**Files:**
- Create: `frontend/src/components/analytics/PsaMetricsPanel.tsx`
- Modify: `frontend/src/pages/FlowPilotAnalyticsPage.tsx`
**Step 1: Build PsaMetricsPanel component**
- **Metric cards row** (3 cards): Total Time Entries, Total Hours Logged, Avg Hours/Session
- **Push success funnel**: horizontal bar visualization showing conversion at each step (sessions → linked → pushed → time entry). Show counts + percentage between steps.
- **Trend chart**: Recharts `AreaChart` with dual Y-axes — entries (bars) and hours (area) over the period
**Step 2: Wire into tab, run build, commit**
```bash
git commit -m "feat(analytics): add PSA metrics panel with funnel and trend chart"
```
---
## Task 10: Final verification and docs
**Step 1: Run full backend tests**
```bash
cd /projects/patherly/backend && python -m pytest --override-ini="addopts="
```
**Step 2: Run frontend build**
```bash
cd /projects/patherly/frontend && npm run build
```
**Step 3: Update CURRENT-STATE.md**
Mark Phase 5 as complete. Update What's Next.
**Step 4: Commit**
```bash
git commit -m "docs: update CURRENT-STATE.md — Phase 5 Analytics Enhancement complete"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
# Search & Recall + Evidence-Rich Sessions — Design Document
> **Date:** 2026-03-20
> **Status:** Approved
> **Source:** Stack priorities plan items #1 (Search and recall improvements) and #2 (Evidence-rich sessions)
---
## Overview
Two complementary features that make ResolutionFlow a team memory system, not just a flow runner. Engineers can capture proof (screenshots, logs, command output) during troubleshooting via clipboard paste, and search across all past sessions by content, domain, ticket, or semantic similarity.
---
## Feature 1: Evidence-Rich Sessions
### Storage
- **Railway Object Storage** — S3-compatible bucket provisioned via Railway dashboard/CLI
- Backend uses `boto3` with S3-compatible endpoint configured via env vars:
- `STORAGE_ENDPOINT` — Railway bucket endpoint
- `STORAGE_ACCESS_KEY` — bucket access key
- `STORAGE_SECRET_KEY` — bucket secret key
- `STORAGE_BUCKET_NAME` — bucket name
- `STORAGE_REGION` — region (default: us-east-1)
### Data Model
New `file_uploads` table:
| Column | Type | Notes |
|--------|------|-------|
| id | UUID PK | |
| account_id | UUID FK → accounts | Tenant scope |
| uploaded_by | UUID FK → users | Who uploaded |
| session_id | UUID FK → ai_sessions (nullable) | Linked session |
| filename | String(255) | Original filename |
| content_type | String(100) | MIME type |
| size_bytes | Integer | File size |
| storage_key | String(500) | S3 object key |
| created_at | DateTime(tz) | |
### API
```
POST /uploads — Multipart file upload, returns upload record with presigned URL
GET /uploads/{id}/url — Presigned download URL (time-limited, 1 hour)
GET /uploads?session_id={id} — List uploads for a session
DELETE /uploads/{id} — Delete upload + S3 object
```
### Limits
- Image types: PNG, JPEG, GIF, WebP — max 5MB each
- Text types: .txt, .log, .csv — max 1MB each
- Per-session: 20 files, 50MB total
- Rate limit: 10 uploads/minute per user
### Clipboard Paste UX
A `RichTextInput` component that wraps textareas with clipboard paste support:
- Listens for `paste` event on the textarea
- Detects image blobs in `clipboardData.items`
- On image paste: uploads in background via `POST /uploads`, shows inline thumbnail with progress
- Thumbnail states: uploading (spinner overlay) → success (image preview) → error (retry button)
- Text content and image references stored together in JSONB
- Images rendered as small thumbnails below the textarea, removable with X button
**Where it's used:**
- FlowPilot intake textarea
- Free-text response input (escape hatch)
- Escalation reason textarea
- Session scratchpad
### Evidence in Exports
Extend the existing export service:
- Markdown: images as `![filename](presigned_url)` links
- HTML/PDF: images embedded as `<img>` with presigned URLs
- PSA: image URLs listed as references (ConnectWise notes don't support inline images)
---
## Feature 2: Search & Recall
### Layer 1: Structured Filters
Extend `GET /ai-sessions` with query parameters:
| Param | Type | Filter |
|-------|------|--------|
| problem_domain | string | Exact match on domain |
| matched_flow_id | UUID | Sessions that matched a specific flow |
| confidence_tier | string | guided / exploring / discovery |
| ticket_id | string | PSA ticket ID |
| date_from | datetime | Sessions created after |
| date_to | datetime | Sessions created before |
| q | string | Full-text search (Layer 2) |
Frontend: add filter bar to AI Sessions tab on SessionHistoryPage — domain dropdown, confidence pills, date range, search input. Match the existing Flow Sessions filter pattern.
### Layer 2: Content Search (PostgreSQL FTS)
Add generated `tsvector` column to `ai_sessions`:
```sql
ALTER TABLE ai_sessions ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
coalesce(intake_summary, '') || ' ' ||
coalesce(resolution_summary, '') || ' ' ||
coalesce(escalation_reason, '') || ' ' ||
coalesce(problem_domain, ''))
) STORED;
CREATE INDEX idx_ai_sessions_search ON ai_sessions USING gin(search_vector);
```
The `q` parameter uses `plainto_tsquery('english', q)` against this vector. Same pattern as existing tree FTS search.
Extend Command Palette to search AI sessions alongside flows.
### Layer 3: Similar Session Matching
New endpoint: `GET /ai-sessions/{id}/similar?limit=5`
- Generates embedding for the session's intake_summary using existing Voyage AI integration
- New `ai_session_embeddings` table (same pattern as `tree_embeddings`): `session_id`, `embedding` (pgvector)
- Embeddings generated on session creation (after intake) and updated on resolution
- Cosine similarity query against all session embeddings for the account
- Returns top N matches with similarity score and session summary
**Where similar sessions appear:**
- FlowPilot session sidebar: "Similar Past Sessions" section (3-5 matches)
- Session detail page: "Related Sessions" at bottom
- Both show: session name/summary, resolution status, date, similarity %
---
## Implementation Order
1. **File upload infrastructure** — S3 service, file_uploads model, upload/download endpoints
2. **RichTextInput component** — clipboard paste handler, thumbnail rendering, upload integration
3. **Wire into FlowPilot** — intake, free-text, escalation, scratchpad
4. **Evidence in exports** — extend export service with image references
5. **Structured filters** — extend AI session list endpoint + frontend filter bar
6. **Content search (FTS)** — migration for tsvector + GIN index, wire into list endpoint
7. **Command palette session search** — extend CommandPalette with AI session results
8. **Similar session matching** — embeddings table, generation service, similar endpoint
9. **Similar sessions UI** — sidebar section + session detail section

View File

@@ -0,0 +1,589 @@
# Search & Recall + Evidence-Rich Sessions — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add file upload with clipboard paste for evidence capture during FlowPilot sessions, and build three layers of session search (structured filters, full-text, semantic similarity).
**Architecture:** Evidence uses Railway Object Storage (S3-compatible) via boto3, with a `file_uploads` table tracking metadata. Search uses PostgreSQL generated tsvector columns with GIN indexes for full-text, and Voyage AI embeddings with pgvector for semantic similarity (reusing existing RAG infrastructure). A `RichTextInput` component handles clipboard paste-to-upload UX.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), boto3 (S3), pgvector, Voyage AI embeddings, React 19, TypeScript, Tailwind CSS v4
**Design doc:** `docs/plans/2026-03-20-search-recall-evidence-design.md`
---
## Part A: Evidence-Rich Sessions
### Task 1: S3 storage service + file_uploads model
**Files:**
- Create: `backend/app/services/storage_service.py`
- Create: `backend/app/models/file_upload.py`
- Modify: `backend/app/models/__init__.py`
- Modify: `backend/app/core/config.py`
- Create: migration
**Step 1: Add storage config**
In `backend/app/core/config.py`, add to the Settings class:
```python
# Object Storage (Railway S3-compatible)
STORAGE_ENDPOINT: str | None = None
STORAGE_ACCESS_KEY: str | None = None
STORAGE_SECRET_KEY: str | None = None
STORAGE_BUCKET_NAME: str = "resolutionflow-uploads"
STORAGE_REGION: str = "us-east-1"
```
**Step 2: Create file_uploads model**
`backend/app/models/file_upload.py`:
```python
"""File upload metadata — tracks files stored in S3-compatible object storage."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class FileUpload(Base):
__tablename__ = "file_uploads"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True
)
uploaded_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str] = mapped_column(String(100), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[str] = mapped_column(String(500), nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
```
Register in `backend/app/models/__init__.py`.
**Step 3: Create storage service**
`backend/app/services/storage_service.py`:
```python
"""S3-compatible object storage service for file uploads."""
import logging
import uuid
from io import BytesIO
import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from app.core.config import settings
logger = logging.getLogger(__name__)
ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
ALLOWED_TEXT_TYPES = {"text/plain", "text/csv", "application/octet-stream"}
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB
MAX_FILES_PER_SESSION = 20
MAX_BYTES_PER_SESSION = 50 * 1024 * 1024 # 50MB
PRESIGNED_URL_EXPIRY = 3600 # 1 hour
def _get_client():
"""Get S3 client configured for Railway Object Storage."""
if not settings.STORAGE_ENDPOINT:
raise RuntimeError("Object storage not configured (STORAGE_ENDPOINT missing)")
return boto3.client(
"s3",
endpoint_url=settings.STORAGE_ENDPOINT,
aws_access_key_id=settings.STORAGE_ACCESS_KEY,
aws_secret_access_key=settings.STORAGE_SECRET_KEY,
region_name=settings.STORAGE_REGION,
config=BotoConfig(signature_version="s3v4"),
)
def validate_upload(content_type: str, size_bytes: int) -> str | None:
"""Validate file type and size. Returns error message or None."""
if content_type not in ALLOWED_TYPES:
return f"File type {content_type} not allowed"
max_size = MAX_IMAGE_SIZE if content_type in ALLOWED_IMAGE_TYPES else MAX_TEXT_SIZE
if size_bytes > max_size:
return f"File too large ({size_bytes} bytes, max {max_size})"
return None
async def upload_file(
file_data: bytes,
filename: str,
content_type: str,
account_id: str,
) -> str:
"""Upload file to S3, returns the storage key."""
ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin"
storage_key = f"uploads/{account_id}/{uuid.uuid4()}.{ext}"
client = _get_client()
client.upload_fileobj(
BytesIO(file_data),
settings.STORAGE_BUCKET_NAME,
storage_key,
ExtraArgs={"ContentType": content_type},
)
return storage_key
def get_presigned_url(storage_key: str) -> str:
"""Generate a time-limited presigned URL for downloading a file."""
client = _get_client()
return client.generate_presigned_url(
"get_object",
Params={"Bucket": settings.STORAGE_BUCKET_NAME, "Key": storage_key},
ExpiresIn=PRESIGNED_URL_EXPIRY,
)
async def delete_file(storage_key: str) -> None:
"""Delete a file from S3."""
try:
client = _get_client()
client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key)
except ClientError:
logger.warning(f"Failed to delete S3 object: {storage_key}")
```
**Step 4: Generate migration, add boto3 to requirements**
```bash
pip install boto3
echo "boto3>=1.34.0" >> backend/requirements.txt
cd backend && alembic revision --autogenerate -m "add file_uploads table"
alembic upgrade head
```
**Step 5: Commit**
```bash
git commit -m "feat(evidence): add S3 storage service and file_uploads model"
```
---
### Task 2: Upload API endpoints
**Files:**
- Create: `backend/app/api/endpoints/uploads.py`
- Create: `backend/app/schemas/upload.py`
- Modify: `backend/app/api/router.py`
- Create: `backend/tests/test_uploads.py`
**Schemas** (`backend/app/schemas/upload.py`):
```python
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class FileUploadResponse(BaseModel):
id: UUID
filename: str
content_type: str
size_bytes: int
url: str # presigned URL
created_at: datetime
model_config = {"from_attributes": True}
```
**Endpoints** (`backend/app/api/endpoints/uploads.py`):
```
POST /uploads — Multipart upload, returns FileUploadResponse
GET /uploads/{id}/url — Presigned download URL
GET /uploads?session_id={id} — List uploads for a session
DELETE /uploads/{id} — Delete upload + S3 object
```
Key details:
- `POST /uploads` accepts `UploadFile` from FastAPI + `session_id` form field
- Validates content_type and size via `storage_service.validate_upload()`
- Checks per-session limits (count + total bytes) before uploading
- Rate limit: `@limiter.limit("10/minute")`
- All endpoints require auth via `get_current_active_user`
- Delete: verify ownership (uploaded_by == current_user.id OR user is admin)
**Tests:** Upload happy path, type rejection, size rejection, per-session limit, presigned URL, delete.
**Commit:**
```bash
git commit -m "feat(evidence): add file upload/download API endpoints"
```
---
### Task 3: Frontend — RichTextInput component
**Files:**
- Create: `frontend/src/components/common/RichTextInput.tsx`
- Create: `frontend/src/api/uploads.ts`
- Create: `frontend/src/types/upload.ts`
**Types** (`frontend/src/types/upload.ts`):
```typescript
export interface FileUploadResponse {
id: string
filename: string
content_type: string
size_bytes: number
url: string
created_at: string
}
export interface PendingUpload {
id: string // temp client ID
file: File
preview: string // object URL for thumbnail
status: 'uploading' | 'done' | 'error'
result?: FileUploadResponse
error?: string
}
```
**API** (`frontend/src/api/uploads.ts`):
```typescript
import apiClient from './client'
import type { FileUploadResponse } from '@/types/upload'
export const uploadsApi = {
async upload(file: File, sessionId?: string): Promise<FileUploadResponse> {
const formData = new FormData()
formData.append('file', file)
if (sessionId) formData.append('session_id', sessionId)
const response = await apiClient.post<FileUploadResponse>('/uploads', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
async getUrl(id: string): Promise<string> {
const response = await apiClient.get<{ url: string }>(`/uploads/${id}/url`)
return response.data.url
},
async list(sessionId: string): Promise<FileUploadResponse[]> {
const response = await apiClient.get<FileUploadResponse[]>('/uploads', { params: { session_id: sessionId } })
return response.data
},
async remove(id: string): Promise<void> {
await apiClient.delete(`/uploads/${id}`)
},
}
```
**RichTextInput component** (`frontend/src/components/common/RichTextInput.tsx`):
Props:
```typescript
interface RichTextInputProps {
value: string
onChange: (value: string) => void
onFilesChange?: (uploads: FileUploadResponse[]) => void
sessionId?: string
placeholder?: string
rows?: number
className?: string
disabled?: boolean
}
```
Behavior:
- Renders a `<textarea>` with standard styling
- Listens for `paste` event — if `clipboardData.items` contains `image/*` blob, extracts it
- On image paste: creates a `PendingUpload`, shows thumbnail strip below textarea, calls `uploadsApi.upload()` in background
- Thumbnail strip: horizontal row of image previews (64x64 rounded), each with:
- Uploading: pulse animation overlay
- Done: image thumbnail, X button to remove
- Error: red border, retry button
- Completed uploads reported to parent via `onFilesChange`
- Also supports drag-and-drop (same handler)
- Design: thumbnails in a `flex gap-2 flex-wrap` row below the textarea, `.glass-card` styling on each thumbnail
**Build and commit:**
```bash
cd frontend && npm run build
git commit -m "feat(evidence): add RichTextInput with clipboard paste upload"
```
---
### Task 4: Wire RichTextInput into FlowPilot
**Files:**
- Modify: `frontend/src/components/flowpilot/FlowPilotIntake.tsx`
- Modify: `frontend/src/components/flowpilot/FlowPilotOptions.tsx` (free-text input)
- Modify: `frontend/src/components/flowpilot/EscalateModal.tsx`
Replace plain `<textarea>` elements in these components with `<RichTextInput>`. Pass the current session ID so uploads are linked.
For intake: the session doesn't exist yet at intake time. Upload without session_id, then link uploads to the session after creation (via a PATCH or by passing upload IDs to the session creation endpoint).
Alternative simpler approach: upload to a temporary session_id (null), then after session creation, call `PATCH /uploads/{id}` to set session_id. Or include upload_ids in the session creation payload.
**Build and commit:**
```bash
git commit -m "feat(evidence): wire clipboard paste into FlowPilot intake, free-text, and escalation"
```
---
### Task 5: Evidence in exports
**Files:**
- Modify: `backend/app/services/export_service.py`
Extend each format generator to include file upload references:
- Query `file_uploads` for the session
- **Markdown:** `![{filename}]({presigned_url})` for images, `[{filename}]({presigned_url})` for text files
- **HTML/PDF:** `<img src="{presigned_url}" alt="{filename}" style="max-width: 100%">` for images
- **PSA:** List as `Evidence: {filename} — {presigned_url}` (CW notes don't support inline images)
- **Text:** `[Attachment: {filename}]` with URL on next line
Generate presigned URLs at export time via `storage_service.get_presigned_url()`.
**Commit:**
```bash
git commit -m "feat(evidence): include file uploads in session exports"
```
---
## Part B: Search & Recall
### Task 6: Structured filters on AI sessions
**Files:**
- Modify: `backend/app/api/endpoints/ai_sessions.py`
- Modify: `frontend/src/pages/SessionHistoryPage.tsx`
**Backend:** Extend `list_sessions` endpoint with new query params:
```python
async def list_sessions(
...,
problem_domain: Optional[str] = Query(None),
matched_flow_id: Optional[UUID] = Query(None),
confidence_tier: Optional[str] = Query(None, pattern="^(guided|exploring|discovery)$"),
ticket_id: Optional[str] = Query(None),
date_from: Optional[datetime] = Query(None),
date_to: Optional[datetime] = Query(None),
q: Optional[str] = Query(None, min_length=2, max_length=200),
):
```
Add `.where()` clauses for each non-None filter.
**Frontend:** Add a filter bar to the AI Sessions tab on `SessionHistoryPage`:
- Search input (for `q` param)
- Problem domain dropdown (populated from distinct domains in sessions)
- Confidence tier pills (All / Guided / Exploring / Discovery)
- Date range inputs
- Match existing Flow Sessions filter bar styling
**Commit:**
```bash
git commit -m "feat(search): add structured filters to AI session list"
```
---
### Task 7: Full-text search (PostgreSQL FTS)
**Files:**
- Create: migration for tsvector column + GIN index
- Modify: `backend/app/api/endpoints/ai_sessions.py`
**Migration:**
```python
# Generated tsvector column on ai_sessions
op.execute("""
ALTER TABLE ai_sessions ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
coalesce(intake_summary, '') || ' ' ||
coalesce(resolution_summary, '') || ' ' ||
coalesce(escalation_reason, '') || ' ' ||
coalesce(problem_domain, ''))
) STORED
""")
op.execute("""
CREATE INDEX IF NOT EXISTS idx_ai_sessions_search
ON ai_sessions USING gin(search_vector)
""")
```
**Wire into list endpoint:** When `q` is provided, add:
```python
from sqlalchemy import text as sa_text
query = query.where(
sa_text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)")
).params(q=q)
```
**Extend Command Palette:** In `frontend/src/components/layout/CommandPalette.tsx`, add AI session search alongside flows. Call the AI sessions list endpoint with `q` param. Show results in a new "AI Sessions" group.
**Commit:**
```bash
git commit -m "feat(search): add PostgreSQL FTS on AI sessions with Command Palette integration"
```
---
### Task 8: Similar session matching (semantic)
**Files:**
- Create: `backend/app/models/ai_session_embedding.py`
- Create: migration
- Modify: `backend/app/api/endpoints/ai_sessions.py`
- Modify: `backend/app/services/flowpilot_engine.py`
**Model** (`backend/app/models/ai_session_embedding.py`):
Same pattern as `tree_embedding.py`:
```python
class AISessionEmbedding(Base):
__tablename__ = "ai_session_embeddings"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False, unique=True, index=True
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False, index=True
)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
embedding_model: Mapped[str] = mapped_column(String(50), nullable=False, default="voyage-3.5")
# embedding column created in migration as vector(1024)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
```
Migration adds the table + `embedding vector(1024)` column via raw SQL (same as tree_embeddings migration).
**Generate embedding:** In `flowpilot_engine.py`, after session intake is processed, generate an embedding of the intake_summary using the existing `rag_service` / Voyage AI client. Store in `ai_session_embeddings`. Update embedding on resolution (add resolution_summary to the text).
**Endpoint:** `GET /ai-sessions/{id}/similar?limit=5`
```python
@router.get("/{session_id}/similar")
async def get_similar_sessions(session_id, current_user, db, limit=5):
# Get this session's embedding
# Cosine similarity query against all session embeddings for the account
# Return top N with similarity score + session summary
```
Use the same cosine similarity query pattern as `rag_service.search()`.
**Commit:**
```bash
git commit -m "feat(search): add semantic similar session matching via Voyage AI embeddings"
```
---
### Task 9: Similar sessions UI
**Files:**
- Create: `frontend/src/components/flowpilot/SimilarSessions.tsx`
- Modify: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Modify: `frontend/src/api/flowpilotAnalytics.ts` (or ai-sessions API)
**Component** — small card list showing 3-5 similar sessions:
```typescript
interface SimilarSession {
id: string
intake_summary: string
status: string
resolution_summary: string | null
problem_domain: string | null
similarity: number
created_at: string
}
```
Each card: `.glass-card` with:
- Problem summary (1-2 lines, truncated)
- Status badge (resolved/escalated)
- Resolution summary if resolved (1 line)
- Similarity percentage
- Click → navigate to session detail
**Where it renders:**
- FlowPilot session sidebar (desktop) or expandable section (mobile): "Similar Past Sessions" below the session info
- Only fetched if the session has been through intake (has intake_summary)
**Build and commit:**
```bash
cd frontend && npm run build
git commit -m "feat(search): add similar sessions UI in FlowPilot sidebar"
```
---
### Task 10: Final verification and docs
**Step 1: Run backend tests**
```bash
cd backend && python -m pytest --override-ini="addopts="
```
**Step 2: Run frontend build**
```bash
cd frontend && npm run build
```
**Step 3: Update CURRENT-STATE.md**
Add evidence-rich sessions and search & recall to completed items.
**Step 4: Update stack priorities doc** — mark items 1 and 2 as complete.
**Step 5: Commit**
```bash
git commit -m "docs: update CURRENT-STATE.md — Search & Recall and Evidence-Rich Sessions complete"
```

View File

@@ -0,0 +1,404 @@
# Copilot-First Dashboard Redesign — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Redesign the dashboard and navigation so the AI chat copilot is the primary experience — an engineer handed a login URL should immediately see "What are you troubleshooting?" and be able to start a conversation, paste images, and drag files, like ChatGPT/Claude.
**Architecture:** The dashboard becomes a chat-first page with a centered input area (textarea + file upload), recent conversations sidebar, and secondary dashboard widgets collapsed below. The sidebar navigation is simplified to put the copilot front and center. Guided troubleshooting (flow-following mode) moves to the sidebar as an option but is no longer the default mode.
**Tech Stack:** React 19, Tailwind CSS v4, existing `RichTextInput` component (drag-drop + paste images), existing FlowPilot/Assistant Chat APIs
**GTM Context:** [resolutionflow-gtm-design.md](resolutionflow-gtm-design.md) — colleague pilot in 1-2 weeks. Engineers should open the app and immediately start troubleshooting, not learn a platform.
---
## Current State → Target State
### Dashboard (QuickStartPage)
**Current:** Greeting → Onboarding checklist → Small input bar → Escalations → Active sessions → Stat cards → Knowledge Base + Team Summary → Recent sessions
**Target:** Large centered chat input (ChatGPT-style) with:
- Greeting + subtitle ("What are you troubleshooting?")
- Large textarea with paste/drag-drop file support (reuse `RichTextInput`)
- Quick suggestion chips below ("VPN not connecting", "Outlook not syncing", "User locked out")
- Recent conversations list below the input (last 5-10 sessions)
- Secondary widgets (stats, KB, team summary) collapsed into a "Dashboard" section at the bottom or accessible via sidebar
### Sidebar Navigation
**Current:** Home → Work (Sessions, Escalations) → Know (Flows, Step Library, Scripts, Builder, Review) → Data (Exports, Analytics, FP Analytics) → Help (Guides, Feedback) → Acct
**Target:** New Conversation (top, prominent) → History (recent sessions) → Guided Flows → Scripts → Analytics → Account. The sidebar should feel like a chat app sidebar, not an enterprise nav.
### Input Experience
**Current:** Single-line `<input>` with mode toggle (Guided/Chat). No file support on the dashboard input.
**Target:** Multi-line `<textarea>` that auto-grows. Supports:
- Paste images (Ctrl+V / Cmd+V) — screenshots of errors, Event Viewer, etc.
- Drag and drop files (images, logs, documents)
- Thumbnail preview strip for attached files
- Enter to send, Shift+Enter for newline
- No mode toggle — default is always the chat copilot. Guided mode accessible from sidebar.
---
## File Map
### Task 1 — Dashboard Redesign
| Action | File |
|--------|------|
| Rewrite | `frontend/src/pages/QuickStartPage.tsx` |
| Modify | `frontend/src/components/dashboard/StartSessionInput.tsx` |
| Reference | `frontend/src/components/common/RichTextInput.tsx` |
### Task 2 — Sidebar Simplification
| Action | File |
|--------|------|
| Modify | `frontend/src/components/layout/Sidebar.tsx` |
### Task 3 — Onboarding Update
| Action | File |
|--------|------|
| Modify | `frontend/src/components/dashboard/OnboardingChecklist.tsx` |
---
## Task 1: Copilot-First Dashboard
**Files:**
- Rewrite: `frontend/src/pages/QuickStartPage.tsx`
- Rewrite: `frontend/src/components/dashboard/StartSessionInput.tsx`
### Step 1: Redesign StartSessionInput as a chat-style input
Replace the current single-line input + mode toggle with a ChatGPT-style textarea:
**Layout:**
```
┌─────────────────────────────────────────────────┐
│ ⚡ What are you troubleshooting? │
│ │
│ [Multi-line textarea with placeholder] │
│ [Thumbnail strip if files attached] │
│ │
│ [📎 Attach] [📋 Paste Logs] [Pull Ticket] ⬆ │
└─────────────────────────────────────────────────┘
Suggestion chips: [VPN issues] [Outlook sync] [AD lockout] [Permission denied]
```
**Behavior:**
- Textarea auto-grows up to 200px, then scrolls
- Paste images via Ctrl+V — shows thumbnail preview (reuse `RichTextInput` paste/drag logic)
- Drag and drop files — shows thumbnail strip
- **📎 Attach button** opens native file picker dialog. Accepted types: images (png, jpg, gif, webp), logs (.txt, .log, .csv), documents (.pdf, .docx). Uses hidden `<input type="file" multiple accept="..." />` triggered by button click.
- "Paste Logs" button expands a secondary textarea for raw log content
- "Pull from Ticket" button opens ticket picker (only shown if PSA connected)
- Send button (arrow icon) or Enter submits → navigates to `/pilot` with prefill + attached files
- Shift+Enter for newline
- No Guided/Chat toggle — submits to the copilot (FlowPilot) by default
**Session ending (already built):**
The FlowPilot session page has a bottom action bar with: Resolve (write summary → generate docs), Escalate (hand off), Pause (save & resume later), Close (end without resolution). No changes needed.
**Key decisions:**
- Remove the "Guided" vs "Open Chat" mode toggle from the main input. The default path is always the AI copilot (FlowPilot). Guided flows are accessible from the sidebar under "Guided Flows."
- The input navigates to `/pilot` (FlowPilot session page) on submit — same as current "Guided" mode. The FlowPilot is the copilot.
### Step 2: Redesign QuickStartPage layout
**New layout (top to bottom):**
```
┌──────────────────────────────────────────────────────┐
│ Good evening, Michael │
│ What are you troubleshooting? │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ [Large chat-style input with file support] │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ [VPN issues] [Outlook sync] [AD lockout] [Printer] │
│ │
│ ── Recent Sessions ──────────────────────────── │
│ ⚡ Mapped drive issue · 3d ago · 6 steps · Resume │
│ ⚡ Exchange config · 5d ago · Resolved │
│ │
│ ── Performance ────────────────────────── [expand] │
│ [Stats cards — collapsed by default, expandable] │
│ │
│ ── Knowledge ──────────────────────────── [expand] │
│ [KB + Team cards — collapsed by default] │
└──────────────────────────────────────────────────────┘
```
**Key changes from current:**
- Greeting is smaller, subtitle becomes "What are you troubleshooting?" in large text
- Chat input is the hero element — takes up the visual center
- Suggestion chips provide quick-start for common issues
- Recent sessions shown inline (not in a card) — just a clean list
- OnboardingChecklist moves below recent sessions or becomes a subtle banner
- PerformanceCards, KnowledgeBaseCards, TeamSummary collapse into expandable sections at the bottom — visible but not competing with the input
- PendingEscalations stays auto-hiding (only shows when there are escalations)
### Step 3: Implement suggestion chips
Hardcoded initial set (can be made dynamic later based on team's common issues):
```typescript
const SUGGESTIONS = [
'VPN not connecting',
'Outlook not syncing',
'User locked out of account',
'Slow internet at client site',
'Printer not working',
'MFA issues',
]
```
Clicking a chip fills the input and auto-submits (navigates to `/pilot` with that text).
### Step 4: Verify build
Run: `cd frontend && npx tsc --noEmit`
Expected: Zero errors
### Step 5: Commit
```bash
git add frontend/src/pages/QuickStartPage.tsx frontend/src/components/dashboard/StartSessionInput.tsx
git commit -m "feat: copilot-first dashboard — chat-style input with file support
Large textarea with paste/drag-drop images, suggestion chips,
collapsible secondary widgets. Removes Guided/Chat toggle —
copilot is always the default path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Sidebar Navigation Simplification
**Files:**
- Modify: `frontend/src/components/layout/Sidebar.tsx`
### Step 1: Restructure the icon rail
**New rail groups (top to bottom):**
```
[⚡ New] — always visible, prominent (accent colored)
Clicking starts a fresh copilot session (navigates to /)
─────────
[🏠 Home] — Dashboard
[📂 History] — Session history (/sessions)
[🔀 Flows] — Guided troubleshooting flows (/trees) — the guided mode lives here
[📜 Scripts] — Script library + builder
[📊 Data] — Analytics + Exports
─────────
[⚙ Acct] — Account settings
[📌 Pin] — Pin/unpin sidebar
```
**Key changes:**
- **"New" button at the top** — like ChatGPT's "New Chat" button. Always visible, uses accent color. Navigates to `/` (dashboard with fresh input).
- **"History"** replaces "Work" — shows session history (active + completed). This is where engineers find past conversations.
- **"Flows"** replaces "Know" — simplified. Contains guided flows and the flow library. Step Library and Review Queue move here as sub-items.
- **"Scripts"** gets its own icon — Script library + Script Builder as sub-items.
- **Help/Guides removed from rail** — moves to account section or footer link. Engineers in a pilot don't need user guides taking up sidebar real estate.
- **Escalations** — stays as a badge on History or as a sub-item, not a top-level icon.
**Pinned mode sub-items:**
```
NEW SESSION
─ RESOLVE ─
Dashboard
Session History (badge: active count)
Escalations (badge: count, only if > 0)
─ KNOWLEDGE ─
Guided Flows
└ Troubleshooting
└ Projects
Scripts
└ Script Library
└ Script Builder
─ INSIGHTS ─
Analytics
Exports
```
### Step 2: Verify build
Run: `cd frontend && npx tsc --noEmit`
### Step 3: Commit
```bash
git commit -m "refactor: simplify sidebar for copilot-first navigation
New Session button at top, History replaces Work, Flows replaces Know,
Scripts gets own icon. Guides/feedback moved out of main nav.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Onboarding Update
**Files:**
- Modify: `frontend/src/components/dashboard/OnboardingChecklist.tsx`
### Step 1: Update onboarding steps
**Current steps:**
1. Create your first flow
2. Run your first session
3. Export a session
4. Try the AI assistant
**New steps (copilot-first):**
1. Try troubleshooting a ticket (navigates to input, focus)
2. Review your session notes (navigates to session history)
3. Explore guided flows (navigates to /trees)
4. Check out the Script Builder (navigates to /script-builder)
The onboarding should guide engineers toward *using the copilot*, not building flows. Building flows is a power-user action they discover later.
### Step 2: Make the checklist a subtle top banner instead of a card
Instead of a prominent card above the input, render it as a slim banner below the input area:
```
Getting started: Try troubleshooting a ticket → Review your notes → Explore flows [Dismiss]
```
This keeps the input as the hero and the onboarding as a gentle nudge.
### Step 3: Verify build
Run: `cd frontend && npx tsc --noEmit`
### Step 4: Commit
```bash
git commit -m "refactor: update onboarding for copilot-first experience
Steps guide toward using the copilot, not building flows.
Checklist rendered as subtle banner below input.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Mobile Responsive
**Files:**
- Modify: `frontend/src/pages/QuickStartPage.tsx`
- Modify: `frontend/src/components/dashboard/StartSessionInput.tsx`
- Modify: `frontend/src/components/layout/AppLayout.tsx` (mobile nav items)
### Step 1: Ensure chat input works on mobile
- Textarea should be full-width on mobile
- Suggestion chips wrap to 2 rows on small screens
- Attach/Paste Logs buttons stack vertically on mobile
- Thumbnail strip scrolls horizontally
### Step 2: Update mobile hamburger nav
Update `mobileNavItems` in `AppLayout.tsx` to match the new sidebar structure:
- Dashboard
- Session History
- Guided Flows
- Scripts
- Analytics
- Account
### Step 3: Verify build
Run: `cd frontend && npx tsc --noEmit`
### Step 4: Commit
```bash
git commit -m "fix: mobile responsive copilot-first dashboard
Chat input full-width, suggestion chips wrap, mobile nav updated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Remove Maintenance Flows
**Files:**
- Modify: `frontend/src/components/layout/Sidebar.tsx` — remove Maintenance from nav sub-items and pinned mode children
- Modify: `frontend/src/components/layout/AppLayout.tsx` — remove from mobile nav if present
- Modify: `frontend/src/pages/TreeListPage.tsx` — remove Maintenance tab from flow type filters
- Modify: `frontend/src/components/library/TreeGridView.tsx` — remove maintenance badge rendering
- Modify: `frontend/src/components/library/TreeListView.tsx` — same
- Modify: `frontend/src/components/library/TreeTableView.tsx` — same
- Modify: `frontend/src/components/common/CreateFlowDropdown.tsx` — remove Maintenance as a create option
- Modify: `frontend/src/components/layout/Sidebar.tsx` — remove Maintenance from rail group children and pinned sections
### Step 1: Remove Maintenance from all navigation and flow type filters
Remove `Maintenance` from:
- Sidebar rail group children (Know → Maintenance)
- Sidebar pinned mode sections (Knowledge → Maintenance)
- Flow type filter tabs on the flows page (All / Troubleshooting / Projects / ~~Maintenance~~)
- Create flow dropdown options
- Mobile nav items (if listed)
### Step 2: Remove maintenance-specific UI
Remove from tree view components:
- The amber "Maintenance" badge (`tree.tree_type === 'maintenance'` checks)
- Any maintenance-specific icons (Wrench)
- Maintenance flow batch launch entry points
**Note:** Do NOT remove backend support, database models, or API endpoints for maintenance flows. They can stay dormant. This is a frontend-only removal for the pilot to reduce confusion.
### Step 3: Verify build
Run: `cd frontend && npx tsc --noEmit`
### Step 4: Commit
```bash
git commit -m "refactor: remove maintenance flows from UI for pilot
Maintenance flows hidden from navigation, flow type filters, create
dropdown, and tree view badges. Backend support preserved — can be
re-enabled post-pilot if validated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
## What's NOT in this plan (intentionally deferred)
- **Solutions Library** — spec written, builds after pilot ([2026-03-23-solutions-library-design.md](2026-03-23-solutions-library-design.md))
- **Landing page copy rewrite** — should be updated to match copilot-first messaging, but separate effort
- **FlowPilot session page changes** — the session page itself works fine, we're just changing how people get to it
- **Backend changes** — none needed. The frontend already navigates to `/pilot` with prefill text. File uploads already work via `RichTextInput` + `uploadsApi`.
- **Chat history in sidebar** — showing recent conversations in the sidebar (like ChatGPT's left panel) is a future enhancement. For now, "History" links to the sessions page.
---
## Summary of user experience after implementation
1. Engineer gets a URL, registers/logs in
2. Lands on a page with a big text input: "What are you troubleshooting?"
3. Types their problem, pastes a screenshot, or drags a log file
4. Hits Enter → FlowPilot starts helping them troubleshoot
5. When done, they get clean notes for their ticket
6. Next time, they see their recent sessions below the input
7. Guided flows, scripts, analytics are in the sidebar when they're ready to explore

View File

@@ -0,0 +1,601 @@
# Mid-Session Status Updates — Feature Spec
> **Status:** IMPLEMENT NOW
> **Date:** 2026-03-23
> **Priority:** High — addresses real MSP workflow need during active troubleshooting
---
## Problem
Engineers are mid-ticket, actively troubleshooting in FlowPilot, and need to share progress. Today they have to:
1. Context-switch out of FlowPilot
2. Mentally summarize what they've done
3. Write the update themselves (different tone for ticket notes vs. client emails)
4. Paste it into their PSA or email
This breaks flow, wastes time, and produces inconsistent documentation. The AI already has full context — it should generate the update.
## Solution
**"Share Update"** — a button in the FlowPilot action bar that generates a context-aware status summary, tailored for either internal ticket notes or client-facing communication.
---
## User Flow
### 1. Trigger
Two ways to trigger:
- **Action bar button:** "Share Update" button (blue/cyan, positioned between Escalate and Pause)
- **Chat command:** Engineer types "status update", "give me an update", "share progress" — FlowPilot recognizes the intent
### 2. Two-Step Selection
**Step 1 — Audience:**
FlowPilot responds:
> *"Who is this update for?"*
>
> **[Ticket Notes]** — Technical, for your PSA
> **[Client Update]** — Professional, non-technical
> **[Email Draft]** — Full email with subject line and sign-off
These are rendered as clickable option cards in the modal (or inline buttons in chat).
**Step 2 — Length:**
> *"How detailed?"*
>
> **[Quick]** — 1-2 sentences, just the essentials
> **[Detailed]** — Full breakdown with steps and findings
Both steps are single-click. If the engineer triggers via chat with a specific phrase (e.g., "quick update for the client"), both steps are skipped.
### 3. AI Generates Summary
Based on the full session context (all messages, steps tried, current diagnosis), AI generates the appropriate summary.
#### Ticket Notes Mode
- **Tone:** Technical, concise, factual (customizable per team — see Team Templates below)
- **Format:** PSA-compatible markdown (ConnectWise supports markdown in notes)
- **Content includes:**
- Current status (investigating / identified / implementing fix)
- Steps completed and findings
- What's been ruled out
- Current hypothesis / next steps
- Time spent so far
**Example output (Detailed):**
```
**Status: Investigating**
**Time spent: 22 minutes**
**Steps completed:**
- Verified MX records — correct (pointing to Exchange Online)
- Ran message trace in EAC — emails queuing at transport layer
- Checked recipient mailbox — not full, no forwarding rules
- Reviewed mail flow rules — found suspicious tenant-wide transport rule
**Current diagnosis:**
Transport rule "Block External Senders" appears to be scoped tenant-wide instead of per-group. This is likely blocking all external inbound mail.
**Next steps:**
- Confirm rule scope with client before modifying
- Test with a single mailbox first if client approves
```
**Example output (Quick):**
```
Investigating email delivery issue — 22 min in. Traced to a tenant-wide transport rule blocking external senders. Need client approval to modify scope.
```
#### Client Update Mode
- **Tone:** Professional, reassuring, non-technical (customizable per team — see Team Templates below)
- **Format:** Plain text (suitable for portal message or pasting into chat)
- **Content includes:**
- What we're working on (plain language)
- What we've found so far
- What we're doing next
- Expected next update time (if inferable)
- **Content NEVER includes:**
- Technical jargon (no "transport rules", "MX records", "EAC")
- Server names, IP addresses, internal tool names
- Anything that would confuse a non-technical stakeholder
- **Client name auto-insertion:** If the session has a client/company from intake fields or PSA ticket context, the update addresses them by name ("Hi Acme Medical Group" not generic "Hi")
#### Email Draft Mode
- **Tone:** Same as Client Update but wrapped in full email structure
- **Format:** Complete email ready to paste into Outlook/Gmail
- **Content includes:**
- Subject line (e.g., "Update: Email delivery issue — [Ticket #]")
- Greeting with client name (if available)
- Body (same content as Client Update)
- Professional sign-off with engineer's name (from user profile)
- **Use case:** Many MSPs still communicate via email, not PSA portals
**Example output (Detailed):**
```text
Hi Acme Medical Group,
We're actively working on the email delivery issue. Here's where we stand:
We've confirmed that your email system's core settings are correct — the issue isn't with your email addresses or mailboxes. We've traced it to a mail routing configuration that's preventing incoming emails from being delivered.
We've identified the specific setting that needs to be adjusted and will confirm the change with you before making it. Once approved, we expect delivery to resume within minutes.
We'll update you again shortly.
Best regards,
Michael
```
**Example output (Quick):**
```text
Hi Acme Medical Group — quick update on the email issue. We've identified the cause and have a fix ready. Just need your approval before making the change. Expect resolution within 15 minutes after that. We'll follow up shortly.
```
**Example output (Email Draft):**
```text
Subject: Update: Email delivery issue — TKT-2847
Hi Acme Medical Group,
Just a quick update on the email delivery issue you reported.
We've completed our investigation and identified the cause — a mail routing configuration is preventing incoming emails from being delivered. We have a fix ready and just need your approval before making the change.
Once approved, we expect email delivery to resume within minutes.
Please let us know if you have any questions.
Best regards,
Michael Chihlas
ResolutionFlow
```
### 4. Actions After Generation
The summary appears in the chat as a formatted message with action buttons:
| Button | Action |
| ------ | ------ |
| **Copy** | Copy to clipboard (toast: "Copied to clipboard") |
| **Post to Ticket** | Push directly to ConnectWise ticket as a note (only visible if PSA connected + ticket linked) |
| **Regenerate** | Generate a new version (same audience + length) |
| **Switch Audience** | Switch between ticket notes ↔ client update ↔ email draft |
| **Switch Length** | Toggle quick ↔ detailed |
| **Set Reminder** | Schedule a follow-up reminder (see Follow-up Reminders below) |
**Post to Ticket details:**
- Ticket Notes → posted as **Internal Note** (`internalAnalysisFlag: true`)
- Client Update → posted as **External Note** (`internalAnalysisFlag: false`) — visible to client in their portal
- Uses existing `POST /service/tickets/{id}/notes` ConnectWise endpoint
- Uses existing PSA connection and member mapping from the session
### 5. Multiple Updates Per Session
Engineers can request status updates multiple times during a session. Each update:
- Uses the full conversation context up to that point
- References what changed since the last update (if applicable)
- Gets saved in the session message history (so it's part of the final documentation)
---
## Backend Implementation
### New Endpoint
```
POST /ai-sessions/{session_id}/status-update
```
**Request body:**
```json
{
"audience": "ticket_notes" | "client_update"
}
```
**Response body:**
```json
{
"content": "string — the generated update",
"audience": "ticket_notes" | "client_update",
"session_status": "investigating" | "identified" | "implementing",
"steps_completed": 5,
"time_spent_minutes": 22,
"generated_at": "2026-03-23T14:30:00Z"
}
```
### FlowPilot Engine Addition
Add `generate_status_update()` to `flowpilot_engine.py`:
- Loads session + all steps/messages
- Builds a status-update-specific system prompt based on audience
- Calls the AI model with full conversation context
- Returns formatted update
**System prompt guidance (ticket notes):**
```
You are generating an internal status update for a PSA ticket note.
Be technical, concise, and factual. Use markdown formatting.
Include: current status, steps completed, findings, what's been ruled out, next steps.
Do NOT soften language or add pleasantries.
```
**System prompt guidance (client update):**
```
You are generating a client-facing status update.
Be professional, reassuring, and non-technical.
NEVER use technical jargon, server names, IP addresses, or internal tool names.
Explain findings in plain language a non-technical business owner would understand.
Include: what we're working on, what we've found, what's next.
Keep it brief — 3-5 short paragraphs maximum.
```
### PSA Push (ConnectWise)
Reuse existing infrastructure from session resolution:
- `services/psa/connectwise/client.py` already has `create_ticket_note()`
- Add a new helper or reuse existing: `push_status_update_to_psa(session, content, is_internal)`
- `internalAnalysisFlag` = `true` for ticket notes, `false` for client updates
### Data Model
No new tables needed. Status updates are stored as regular session messages:
- `ai_session_steps` with `step_type = 'status_update'`
- `content` JSONB includes: `{ audience, generated_content, psa_push_status }`
This keeps updates in the session timeline and they'll appear in the final documentation.
---
## Frontend Implementation
### FlowPilotActionBar Changes
Add "Share Update" button to the action bar between Escalate and Pause:
```tsx
<button
onClick={onShareUpdate}
className="flex items-center gap-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 px-4 py-2 min-h-[44px] text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<FileText size={16} />
Share Update
</button>
```
### StatusUpdateModal Component
New modal that handles the audience selection + generated output:
**State 1: Audience Selection**
- Title: "Share Status Update"
- Two large option cards: "Ticket Notes" and "Client Update"
- Each card has icon, title, description
**State 2: Generating**
- Loading spinner with "Generating update..."
- Shows audience selected
**State 3: Result**
- Formatted preview of the generated update
- Action buttons: Copy, Post to Ticket (conditional), Regenerate, Switch Audience
- "Post to Ticket" only visible when `hasPsaTicket` is true
### Chat Integration
When triggered via chat (engineer types "status update"):
- FlowPilot detects the intent in the normal message flow
- Responds with audience selection buttons inline (same as existing action buttons pattern)
- After selection, generates and displays the update in the chat stream
- Copy/Post to Ticket buttons appear below the update message
### API Client
Add to `frontend/src/api/aiSessions.ts`:
```typescript
generateStatusUpdate: (sessionId: string, audience: 'ticket_notes' | 'client_update') =>
apiClient.post(`/ai-sessions/${sessionId}/status-update`, { audience })
```
### Hook Extension
Add to `useFlowPilotSession`:
```typescript
generateStatusUpdate: async (audience: 'ticket_notes' | 'client_update') => {
const result = await aiSessionsApi.generateStatusUpdate(sessionId, audience)
return result
}
```
---
## Chat Intent Detection
Add status update detection to the FlowPilot engine's intent recognition:
**Trigger phrases:**
- "status update"
- "give me an update"
- "share progress"
- "write a summary"
- "update for the ticket"
- "update for the client"
- "what should I tell the client"
- "ticket note"
If the phrase specifies audience and/or length (e.g., "quick update for the client"), skip those steps and generate directly.
---
## Team Communication Templates
Team admins can customize the default tone and format for status updates via Account Settings → Communication Templates.
### Configurable settings
| Setting | Options | Default |
| ------- | ------- | ------- |
| Client tone | Professional, Friendly, Formal | Professional |
| Client sign-off | Custom text or auto (engineer name) | Auto |
| Company name in sign-off | Include / Exclude | Include |
| Ticket notes format | Markdown, Plain text | Markdown |
| Default length | Quick, Detailed | Detailed |
### Tone examples
**Professional (default):**
> "We've identified the cause and have a fix ready."
**Friendly:**
> "Good news — we've found what's causing this and we're ready to fix it!"
**Formal:**
> "We wish to inform you that we have completed our investigation and identified the root cause of the reported issue."
### Storage
Templates stored in `accounts` table as JSONB column `communication_templates`:
```json
{
"client_tone": "professional",
"client_signoff": "auto",
"include_company_in_signoff": true,
"ticket_notes_format": "markdown",
"default_length": "detailed"
}
```
---
## Follow-up Reminders
After generating a status update, the engineer can set a reminder to send another one.
### Flow
1. After update is generated, "Set Reminder" button appears
2. Quick options: **15 min**, **30 min**, **1 hour**, **Custom**
3. When the timer fires:
- In-app notification: *"Time to send another update to [Client Name] on the email delivery issue"*
- Click notification → opens the session with the Share Update modal pre-loaded
5. If the session was resolved before the reminder fires, auto-dismiss with a note: *"Reminder cancelled — session resolved"*
### Storage
Reminders stored as `ai_session_steps` with `step_type = 'update_reminder'`:
```json
{
"remind_at": "2026-03-23T15:00:00Z",
"audience": "client_update",
"status": "pending" | "fired" | "cancelled"
}
```
Checked via existing APScheduler interval job or a lightweight frontend timer (for v1, frontend `setTimeout` is sufficient since the session is active).
---
## Update History Sidebar
A mini-timeline in the session sidebar showing all status updates sent during the session.
### What it shows
Each entry displays:
- **Audience icon:** clipboard (ticket), user (client), mail (email draft)
- **Timestamp:** "2:15 PM"
- **Length badge:** "Quick" or "Detailed"
- **Delivery status:** Copied / Posted to ticket / Pending reminder
- **Click to expand:** Shows the full generated text
### Location
Inside the existing session sidebar (right side of FlowPilot), below the session info section. Collapsible: "Updates (3)" header that expands to show the timeline.
### Why this matters
Engineers lose track of what they've already communicated, especially on long sessions. This prevents duplicate updates and lets them reference what they told the client earlier.
---
## Resolution Communication
The same audience/length system applies when an engineer **resolves** a session — this is where it matters most.
### Enhanced Resolve Flow
**Current flow:**
1. Engineer clicks Resolve → types summary → auto-generated documentation → done
**Enhanced flow:**
1. Engineer clicks Resolve → types summary → auto-generated documentation
2. **"Share Resolution"** step appears immediately after, with the same options:
- Audience: Ticket Notes / Client Update / Email Draft
- Length: Quick / Detailed
3. Pre-generated based on team's default settings (no extra clicks if defaults are right)
4. Engineer can Copy, Post to Ticket, or skip
### Resolution-Specific Content
#### Ticket Notes (Resolution)
```text
**Status: Resolved**
**Time spent: 35 minutes**
**Root cause: Tenant-wide transport rule blocking external senders**
**Investigation steps:**
- Verified MX records — correct
- Ran message trace — emails queuing at transport layer
- Checked recipient mailbox — no issues
- Identified transport rule "Block External" scoped tenant-wide instead of per-group
**Resolution:**
Modified transport rule scope from tenant-wide to security group "External-Block-Group". Verified mail flow resumed within 5 minutes. Confirmed with end user that emails are now being received.
**Recommendations:**
- Review all tenant-wide transport rules quarterly
- Document rule changes in change management system
```
#### Client Update (Resolution)
```text
Hi Acme Medical Group,
Great news — the email delivery issue is resolved.
We found that a mail routing setting was preventing incoming emails from being delivered to your mailboxes. We've corrected the configuration, and email delivery has been confirmed working.
If you or your team notice any further issues with email, please don't hesitate to reach out.
Best regards,
Michael
```
#### Email Draft (Resolution)
```text
Subject: Resolved: Email delivery issue — TKT-2847
Hi Acme Medical Group,
I'm happy to report that the email delivery issue has been resolved.
The cause was a mail routing configuration that was blocking incoming emails. We've corrected this, and your team should now be receiving emails normally. We verified delivery is working before closing the ticket.
If you notice any further issues, please let us know and we'll investigate immediately.
Best regards,
Michael Chihlas
ResolutionFlow
```
### How It Integrates
- Reuses the exact same `generate_status_update()` backend function, with an additional `context: "resolution"` parameter
- The resolution summary the engineer typed feeds into the generation as the authoritative "what fixed it"
- The existing resolve endpoint (`POST /ai-sessions/{id}/resolve`) returns documentation as before, but now the frontend shows the "Share Resolution" step after
- If ConnectWise is connected, "Post to Ticket" pushes the resolution note alongside the existing auto-documentation push
### Escalation Communication
Same system for escalations — when an engineer escalates, offer:
- **Ticket Notes:** Technical handoff note (what was tried, what failed, why it's being escalated)
- **Client Update:** "We're bringing in a specialist to look at this more closely..."
- **Email Draft:** Full email with escalation context
---
## Implementation Plan
### Phase 1: Core (implement now)
1. **Backend:** `generate_status_update()` in `flowpilot_engine.py` + endpoint in `ai_sessions.py`
2. **Frontend:** `StatusUpdateModal` with 2-step selection (audience + length) + "Share Update" button in action bar
3. **Three audiences:** Ticket Notes, Client Update, Email Draft
4. **Two lengths:** Quick and Detailed
5. **Copy to clipboard** functionality
6. **Client name auto-insertion** from intake fields or PSA ticket context
7. **Store as session step** (`step_type = 'status_update'`)
8. **Resolution communication** — "Share Resolution" step after resolve, same audience/length options
9. **Escalation communication** — "Share Escalation" step after escalate, same options
### Phase 2: Update History + Reminders
1. **Update history sidebar** — mini-timeline of all updates/resolutions sent during session
2. **Follow-up reminders** — set timer after sending update, in-app notification when it fires
3. **Auto-dismiss reminders** when session resolves before timer
### Phase 3: Team Templates
1. **Communication Templates** in Account Settings — tone, sign-off, format preferences
2. **Template-aware generation** — system prompts incorporate team's configured tone/style
### Phase 4: PSA Push (implement with ConnectWise integration)
1. **Post to Ticket** button — push to ConnectWise as internal or external note
2. **PSA status indicator** — show success/failure after push
### Phase 5: Chat Integration (polish)
1. **Intent detection** — recognize status update requests in natural language
2. **Inline generation** — audience buttons + update rendered in chat stream
3. **"Since last update" awareness** — reference changes since previous status update
4. **Shorthand commands** — "quick update for the client" skips both selection steps
---
## Edge Cases
- **No messages yet:** Disable "Share Update" until at least 2 message exchanges
- **Session paused/resolved:** Still allow generating updates (useful for post-session documentation)
- **No PSA connected:** Hide "Post to Ticket" button, only show Copy
- **PSA push fails:** Show error toast, keep the generated content available for manual copy
- **Multiple rapid requests:** Debounce — disable button for 3 seconds after generation
- **Very short session:** AI should still produce a useful update, even if minimal ("Currently investigating — gathering initial information")
---
## Success Metrics
- % of sessions where "Share Update" is used (target: 30%+ of sessions >10 minutes)
- Time between update generation and ticket note creation (should be <5 seconds with Post to Ticket)
- Net Promoter feedback on update quality (post-pilot survey)

View File

@@ -0,0 +1,511 @@
# Solutions Library + Smart RAG + Community Knowledge — Design Spec
> **Status:** SPEC ONLY — not implementing yet. Build after colleague pilot (Week 3-4).
> **Date:** 2026-03-23 (updated 2026-03-23 — added Community tier)
> **Context:** [GTM validation plan](resolutionflow-gtm-design.md) — copilot-first, team knowledge flywheel
---
## Problem
Engineers solve the same problems repeatedly across an MSP. Today that knowledge lives in engineers' heads, scattered PSA ticket notes, or nowhere. When an engineer resolves a tricky issue through the FlowPilot copilot, that knowledge dies with the session. The next engineer who hits the same issue starts from scratch.
## Solution
**Solutions Library** — a team knowledge base that builds itself from resolved copilot sessions and feeds back into future sessions via RAG.
Two halves:
1. **Capture & Dedup** — save resolutions from copilot sessions, prevent duplicates
2. **Smart RAG** — FlowPilot pulls from the Solutions Library during live sessions and surfaces relevant prior resolutions
## How It Works
### 1. Resolution Capture (post-session)
When an engineer resolves a copilot session, FlowPilot auto-generates a structured resolution:
```
{
"title": "Exchange Online mailbox not receiving email",
"problem": "User reports not receiving emails in Outlook. OWA also shows no new mail.",
"root_cause": "Mail flow rule blocking external senders due to tenant-wide transport rule misconfiguration",
"resolution_steps": [
"Checked MX records — correct",
"Ran message trace in Exchange Admin Center — messages queued",
"Found transport rule 'Block External' was enabled tenant-wide instead of per-group",
"Disabled rule, emails delivered within 5 minutes"
],
"environment_tags": ["exchange-online", "mail-flow", "transport-rules"],
"auto_detected_category": "Microsoft 365"
}
```
Engineer gets prompted: **"Save this as a reusable solution?"**
- One-click save with auto-generated content
- Can edit title, tags, steps before saving
- Can skip (not every session produces reusable knowledge)
### 2. Dedup Check (on save)
Before saving, system does a similarity search (embedding cosine similarity) against existing solutions.
**If similarity > 0.85 (strong match):**
- Show existing solution side-by-side with new one
- Three options:
- **Merge** — update existing solution with new context/steps (keeps the better version, increments usage count)
- **Keep Both** — they look similar but are actually different problems
- **Discard** — it's the same thing, don't save
**If similarity 0.6-0.85 (partial match):**
- Show as "Related solutions" but save as new by default
- Engineer can choose to merge if they recognize it's the same
**If similarity < 0.6:**
- Save directly, no prompt
### 3. RAG During Live Sessions
When an engineer starts or progresses through a copilot conversation:
1. After the first 2-3 message exchanges (enough context to understand the problem), FlowPilot searches the Solutions Library
2. Uses the conversation context as the query (not just the initial message)
3. If a solution scores above threshold, FlowPilot surfaces it naturally:
> *"I found a similar issue. Sarah resolved an Exchange mail flow problem 3 days ago — she found a transport rule was blocking external senders. Want me to walk you through her resolution?"*
**If engineer says yes:**
- FlowPilot presents the resolution steps one at a time
- Engineer confirms each step worked or skips
- At the end, the solution's usage count increments
**If engineer says no:**
- FlowPilot continues open-ended troubleshooting
- The suggestion is noted (helps tune future relevance)
**Retrieval rules:**
- Only surface solutions from the same team
- Max 1 suggestion per session (don't nag)
- Don't suggest solutions the same engineer saved (they already know)
- Prefer recent solutions over old ones (tie-breaker)
### 4. Confidence Scoring
Each solution gets a confidence score (0-100):
| Event | Score change |
|-------|-------------|
| Saved from resolved session | +50 (base) |
| Another engineer uses it successfully | +15 |
| Engineer accepts RAG suggestion | +10 |
| Engineer rejects RAG suggestion | -5 |
| Multiple engineers save similar (merged) | +20 |
| Not suggested in 90 days | -10 (decay) |
High-confidence solutions are suggested more aggressively. Low-confidence solutions still appear in search but aren't proactively surfaced.
### 5. Solutions Library UI
Replaces the current Step Library page. Card-based grid with:
**Each solution card shows:**
- Title (e.g., "Exchange Online mailbox not receiving email")
- Problem summary (2 lines, truncated)
- Root cause (1 line)
- Tags (environment, category)
- Saved by [engineer name] · [date]
- Used [N] times · Confidence [high/medium/low]
**Page features:**
- Search (full-text + semantic)
- Filter by tag, engineer, confidence, recency
- Sort by most used, most recent, highest confidence
- Manual "Add Solution" button (not just from sessions)
- Edit/delete for solutions you created (team admins can edit any)
---
## Data Model
### `solutions` table
| Column | Type | Notes |
|--------|------|-------|
| id | UUID | PK |
| team_id | UUID | FK to teams |
| created_by | UUID | FK to users |
| title | VARCHAR(255) | |
| problem_description | TEXT | What the user reported |
| root_cause | TEXT | What was actually wrong |
| resolution_steps | JSONB | Array of step strings |
| environment_tags | JSONB | Array of tag strings |
| category | VARCHAR(100) | Auto-detected or manual |
| source_session_id | UUID | FK to ai_sessions (nullable — manual entries have no source) |
| embedding | VECTOR(1536) | For similarity search (pgvector) |
| confidence_score | INTEGER | 0-100, default 50 |
| use_count | INTEGER | Times used via RAG suggestion |
| last_used_at | TIMESTAMPTZ | |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |
### `solution_events` table (for confidence scoring)
| Column | Type | Notes |
|--------|------|-------|
| id | UUID | PK |
| solution_id | UUID | FK to solutions |
| event_type | VARCHAR(30) | 'used', 'accepted', 'rejected', 'merged', 'decayed' |
| user_id | UUID | FK to users (nullable for decay events) |
| session_id | UUID | FK to ai_sessions (nullable) |
| created_at | TIMESTAMPTZ | |
---
## Existing Infrastructure to Reuse
| What exists | Where | How it maps |
|-------------|-------|-------------|
| Knowledge Flywheel | `services/knowledge_flywheel.py` | Session analysis → can generate solution drafts |
| Knowledge Gap Service | `services/knowledge_gap_service.py` | Detects weak options → can flag sessions worth saving |
| RAG in assistant chat | `services/ai_chat_service.py` | Already does retrieval — extend to Solutions Library |
| Step Library UI | `components/step-library/` | Restyle as Solutions Library |
| pgvector | Already in Docker image (`pgvector/pgvector:pg16`) | Embedding storage + similarity search |
| FlowPilot session conclusion | `components/flowpilot/` | Add "Save as Solution" prompt |
---
## Implementation Phases (future)
### Phase 1: Capture & Library
- Solutions table + migrations
- Post-session "Save as Solution" prompt in FlowPilot
- Auto-generate resolution summary from session transcript
- Solutions Library page (replaces Step Library)
- Manual add/edit/delete
### Phase 2: Dedup
- Embedding generation on save (Anthropic or OpenAI embeddings)
- Similarity search on save
- Merge/Keep Both/Discard UI
### Phase 3: Smart RAG
- Mid-session similarity search
- Natural language suggestion in FlowPilot conversation
- Accept/reject tracking
- Confidence scoring + decay job
### Phase 4: Team Intelligence
- "Trending solutions" on dashboard
- "Your team resolved 12 Exchange issues this week" insights
- Solution suggestions in the copilot intake ("Common issues today: VPN, Exchange, AD lockouts")
---
## Open Questions (to answer during pilot)
1. Do engineers actually want to save resolutions, or is it friction?
2. How similar do problems need to be before a suggestion is helpful vs. annoying?
3. Should solutions be editable by the whole team, or only the creator + admins?
4. What's the right moment to prompt "Save as Solution" — right after resolution, or in a follow-up?
5. Do engineers trust AI-generated resolution summaries, or do they want to write their own?
These questions should be answerable after 2-4 weeks of pilot usage.
---
## Community Solutions Library
### Overview
The Solutions Library has three tiers of knowledge:
| Tier | Source | Who sees it |
| ---- | ------ | ----------- |
| **Private** | Your team's resolutions | Your team only |
| **Community** | Anonymized resolutions from all paid users | All paid users |
| **None** | — | Free users (upgrade CTA) |
This creates a network effect moat — every paid user who resolves a ticket makes the product better for all paid users. Solo MSP engineers (who don't have a team to build a knowledge base) get collective wisdom from day one.
### Sharing Permissions
- Any engineer can share their own resolutions to Community (opt-in per resolution)
- Team admins can **retract** any community solution shared by their team
- Team admins can **edit** any community solution from their team (in case something slips through AI sanitization)
- Team admins can toggle community sharing on/off at the **account level** (enabled by default)
- When sharing is disabled, the "Share to Community" button disappears for all team members; existing community solutions from that team remain live but no new ones can be added
### AI Sanitization Pipeline
When an engineer clicks "Share to Community," the resolution goes through an AI sanitization pass before publishing.
**What gets stripped/replaced:**
| Data type | Example | Becomes |
| --------- | ------- | ------- |
| Company names | "Contoso Corp" | `[Company]` |
| Server/host names | "DC01.contoso.local" | `[Domain Controller]` |
| IP addresses | "192.168.1.50" | `[Internal IP]` |
| Usernames | "jsmith@contoso.com" | `[User]` |
| Passwords/secrets | "P@ssw0rd123" | `[REDACTED]` |
| Ticket IDs | "TKT-2847" | `[Ticket]` |
| Client names | "Acme Medical Group" | `[Client]` |
| File paths with org info | `\\contoso-fs01\shares` | `[File Server]\shares` |
**Sanitization pipeline:**
1. **LLM pass** — sanitization prompt: "Replace all identifying information with descriptive placeholders while preserving technical accuracy"
2. **Regex pass** — safety net to catch IP patterns, email formats, UNC paths the LLM might miss
3. **Preview** — show the engineer the sanitized version before publishing; they can edit or cancel
4. **Low-confidence highlights** — if the LLM can't tell whether something is a product name or company name (e.g., "Apollo"), it highlights those sections in yellow for the engineer to confirm
The engineer always approves the sanitized preview before anything goes live.
### AI Confidence Assessment
When a sanitized solution is submitted, AI runs a quality assessment before publishing.
**AI Confidence Score (0-100):**
| Signal | Score impact |
| ------ | ----------- |
| Session ended in "resolved" status | Required (gate — only resolved sessions can be shared) |
| Clear root cause identified | +25 |
| Specific, actionable resolution steps | +20 |
| Problem is generalizable (not hyper-specific to one environment) | +15 |
| Resolution steps are reproducible by another engineer | +15 |
| Session had multiple troubleshooting steps (not a one-liner) | +10 |
| Similar solution already exists in community | -15 |
| Resolution is vague ("restarted and it worked") | -20 |
**Publishing thresholds (admin-configurable):**
- **Score 70+** — Auto-published, visible immediately
- **Score 40-69** — Published with "Unverified" badge, needs community votes to graduate
- **Score < 40** — Rejected with feedback ("This resolution is too vague to be useful. Try adding the specific steps you took.")
### Community Voting
- Any paid user can upvote or downvote a community solution
- Upvote = "This helped me" / Downvote = "This didn't work for me"
- **5 net upvotes** → "Unverified" badge removed, becomes "Community Verified"
- **3 net downvotes** → Solution hidden, flagged for staff review
- Users can leave a comment with their vote — comments go to a ResolutionFlow staff moderation queue
**Ongoing confidence adjustment:**
| Event | Score change |
| ----- | ----------- |
| Community upvote | +5 |
| Community downvote | -8 |
| Used successfully via RAG (community) | +10 |
| Rejected via RAG (community) | -3 |
| Staff review confirms quality | +20 |
| Not used in 120 days | -5 (decay) |
### Admin Controls (Super Admin Panel)
A new "Community" tab in the `/admin` panel with configurable thresholds:
| Setting | Default | Description |
| ------- | ------- | ----------- |
| Auto-publish threshold | 70 | Min AI confidence score to publish immediately |
| Review threshold | 40 | Min score to publish with "Unverified" badge |
| Upvotes to verify | 5 | Net upvotes needed to remove "Unverified" |
| Downvotes to hide | 3 | Net downvotes to auto-hide for review |
| Confidence decay days | 120 | Days of inactivity before score decays |
| Confidence decay amount | 5 | Points lost per decay cycle |
| Community sharing enabled | true | Global kill switch for all community features |
These live in the existing `platform_settings` table (JSONB). The admin panel also shows the **moderation queue** — flagged solutions and user comments requiring staff review.
### RAG Priority (Community Extension)
During live FlowPilot sessions, retrieval works in two passes:
1. **Team pass** — search team's private Solutions Library (existing behavior)
2. **Community pass** — search community solutions (only if team has `community_sharing_enabled` and user is on a paid plan)
**How FlowPilot presents community solutions differently:**
- Team solutions: *"Your colleague Sarah resolved a similar Exchange issue 3 days ago..."*
- Community solutions: *"A community solution with 12 upvotes matches your issue — an engineer found that a transport rule was blocking external senders. Want me to walk you through it?"*
**Community RAG rules:**
- Only surface community solutions with `community_status = 'published'` (not `'unverified'` or `'hidden'`)
- Require AI confidence score 60+ for RAG surfacing
- Team solutions always rank above community solutions at equal similarity
- Still max 1 suggestion per session (team OR community, whichever scores higher)
- If team solution exists at similarity > 0.7, don't bother checking community (team context is better)
### Solutions Library UI (Community Tab)
Add a tab switcher at the top of the existing Solutions Library page:
| Tab | What it shows |
| --- | ------------- |
| **My Team** | Private team solutions (current behavior) |
| **Community** | All published community solutions, searchable/filterable |
Community tab adds:
- Upvote/downvote buttons on each card
- "Community Verified" or "Unverified" badge
- "Used by X engineers" count instead of team member name
- Vote comment modal (optional, sends to staff queue)
- Filter by: confidence, votes, category, recency
### Pricing Gate
| Feature | Free | Paid |
| ------- | ---- | ---- |
| Private Solutions Library (team) | Limited (10 solutions) | Unlimited |
| Save from FlowPilot sessions | Yes | Yes |
| Community browsing | Upgrade CTA | Full access |
| Community RAG in sessions | No | Yes |
| Share to Community | No | Yes |
| Vote on community solutions | No | Yes |
| Reputation badges | No | Yes |
Free users see the "Community" tab but with a blurred preview + upgrade CTA: *"Join 500+ MSP engineers sharing solutions. Upgrade to access the community knowledge base."*
### Reputation & Incentives
**Visible reputation:**
- Profile badge showing contribution tier: **Contributor** (1+ shared) → **Expert** (10+) → **Authority** (50+)
- "Your solutions helped X engineers this month" notification (monthly digest)
- Contributor count visible on user avatar tooltip in the app
**Psychological nudges (v1):**
- **Social proof on share prompt:** After resolving a session, the save prompt says: *"47 engineers solved similar Exchange issues this week using community solutions. Share yours?"*
- **First-share celebration:** Confetti moment + "Welcome to the community!" toast
- **Streak tracking:** "You've shared 3 solutions this week" (consistency bias)
- **Loss aversion (gentle):** "You resolved 8 tickets this month but only shared 2 — 6 solutions that could help others"
**Future incentives (post-v1):**
- Leaderboard on the Community page: top contributors this month (opt-in visibility)
- Early access to new features for top contributors
- "Featured Contributor" badge on profile
- Potential: discount on subscription for consistent contributors
### Community Data Model Extensions
**New columns on `solutions` table:**
| Column | Type | Notes |
| ------ | ---- | ----- |
| visibility | VARCHAR(20) | `'private'`, `'community'` — default `'private'` |
| community_status | VARCHAR(20) | `'published'`, `'unverified'`, `'hidden'`, `'rejected'` — null for private |
| ai_confidence_score | INTEGER | 0-100, from sanitization/quality assessment |
| sanitized_content | JSONB | Anonymized version (title, problem, root_cause, steps) |
| source_solution_id | UUID | FK to solutions — links community copy to private original |
| upvote_count | INTEGER | default 0 |
| downvote_count | INTEGER | default 0 |
| shared_by_team_id | UUID | FK to accounts — which team shared it (for retraction) |
| shared_at | TIMESTAMPTZ | When it was published to community |
**New table: `solution_votes`**
| Column | Type | Notes |
| ------ | ---- | ----- |
| id | UUID | PK |
| solution_id | UUID | FK to solutions (community ones) |
| user_id | UUID | FK to users |
| vote | SMALLINT | +1 or -1 |
| comment | TEXT | Optional — goes to staff review queue |
| created_at | TIMESTAMPTZ | |
| **UNIQUE** | (solution_id, user_id) | One vote per user per solution |
**New table: `community_reports`**
| Column | Type | Notes |
| ------ | ---- | ----- |
| id | UUID | PK |
| solution_id | UUID | FK to solutions |
| reporter_id | UUID | FK to users |
| comment | TEXT | From the vote comment |
| status | VARCHAR(20) | `'pending'`, `'reviewed'`, `'actioned'` |
| reviewed_by | UUID | FK to users (staff) — nullable |
| created_at | TIMESTAMPTZ | |
| reviewed_at | TIMESTAMPTZ | |
**New columns on `accounts` (team settings):**
| Column | Type | Notes |
| ------ | ---- | ----- |
| community_sharing_enabled | BOOLEAN | default `true` — team admin toggle |
**New columns on `users` (reputation):**
| Column | Type | Notes |
| ------ | ---- | ----- |
| community_shares_count | INTEGER | default 0 — denormalized for fast badge lookup |
| community_helped_count | INTEGER | default 0 — how many times their solutions were used |
**Platform settings** (admin-tunable, in existing JSONB):
```json
{
"community_auto_publish_threshold": 70,
"community_review_threshold": 40,
"community_upvotes_to_verify": 5,
"community_downvotes_to_hide": 3,
"community_decay_days": 120,
"community_decay_amount": 5,
"community_enabled": true
}
```
### Community Implementation Phases
Extends the existing implementation phases:
#### Phase 5: Community Sharing
- `visibility` and `community_status` columns on solutions
- AI sanitization pipeline (LLM + regex + preview)
- "Share to Community" button on solution cards
- Team admin toggle for community sharing
- Team admin retract/edit for community solutions
#### Phase 6: Community Quality & Voting
- AI confidence assessment on community submissions
- `solution_votes` table + upvote/downvote UI
- `community_reports` table + staff moderation queue
- Admin-configurable thresholds in platform settings
- "Community" tab in admin panel
#### Phase 7: Community RAG
- Two-pass RAG (team-first, community-second)
- Community solution presentation in FlowPilot
- Paid-plan gating for community features
- Blurred preview + upgrade CTA for free users
#### Phase 8: Reputation & Growth
- Contribution tier badges (Contributor → Expert → Authority)
- Social proof nudges on share prompt
- First-share celebration
- "Helped X engineers" monthly digest
- Streak tracking
---
## Related: FlowPilot Engagement Nudges (Future Design)
Separate from community sharing — how do we nudge engineers to resolve issues through FlowPilot in the first place? This is the top of the funnel. Community sharing nudges are pointless if engineers aren't using FlowPilot to resolve.
**To design:**
- What triggers bring engineers back to FlowPilot?
- How do we make the resolve action feel rewarding (dopamine loop)?
- Post-resolution moments: celebration, streak tracking, team leaderboard
- Push notifications / email digests for stale sessions
- "Your team resolved 12 tickets today" social proof
Design this as a separate spec — it deserves focused thinking.

View File

@@ -0,0 +1,130 @@
# Unified Sessions — Migration Plan
> **Date:** 2026-03-23
> **Status:** Implementation ready
> **Goal:** Merge assistant chat into the ai_sessions system so both guided (FlowPilot) and free-form (chat) sessions share the same data model, history, and action system.
## Problem
Three separate conversation systems exist:
1. `assistant_chats` — free-form chat, JSONB messages inline, no steps
2. `ai_sessions` + `ai_session_steps` — guided FlowPilot, separate steps table
3. `ai_chat_sessions` — flow builder (unrelated, leave alone)
This causes:
- No unified session history
- Assistant chats missing Resolve/Escalate/Update actions
- Dashboard can't show assistant chats in active/recent
- Two separate API surfaces to maintain
## Solution
Add `session_type` to `ai_sessions`. Chat sessions use `conversation_messages` JSONB for message history (already exists). Both types share the same status, PSA, escalation, and documentation features.
## Data Model Changes
### Migration: Add `session_type` to `ai_sessions`
```sql
ALTER TABLE ai_sessions ADD COLUMN session_type VARCHAR(10) NOT NULL DEFAULT 'guided';
-- Values: 'guided' (FlowPilot), 'chat' (assistant)
```
### How chat sessions use ai_sessions
| ai_sessions column | Chat usage |
|---|---|
| `session_type` | `'chat'` |
| `intake_type` | `'free_text'` |
| `intake_content` | `{text: "first message"}` |
| `conversation_messages` | Full chat history as JSONB array `[{role, content}]` |
| `status` | Same: active/resolved/escalated/paused/abandoned |
| `problem_summary` | AI-generated from first few messages |
| `problem_domain` | AI-detected domain |
| `step_count` | Message count (for display) |
| `resolution_summary` | Set on resolve |
| `escalation_reason` | Set on escalate |
| All PSA fields | Same — can link tickets, push notes |
| All timestamps | Same |
### What chat sessions DON'T use
- `ai_session_steps` table — no steps, messages are in `conversation_messages`
- `matched_flow_id` / `match_score` — no flow matching
- `confidence_tier` / `confidence_score` — no structured confidence
- `system_prompt_snapshot` — could store chat system prompt
### New field on ai_sessions
- `title` (String 255, nullable) — chat sessions need a title for the sidebar. Guided sessions can use `problem_summary`.
## Implementation Phases
### Phase 1: Backend — Model & Migration
- [ ] Alembic migration: add `session_type` VARCHAR(10) default 'guided', add `title` VARCHAR(255) nullable
- [ ] Update AISession model with new columns
- [ ] Update schemas: add `session_type` and `title` to response schemas
- [ ] Add session_type filter to GET /ai-sessions endpoint
### Phase 2: Backend — Chat API on ai_sessions
- [ ] Create new endpoints or extend existing:
- `POST /ai-sessions` with `session_type: 'chat'` — creates a chat session
- `POST /ai-sessions/{id}/chat` — send message, get AI response (appends to conversation_messages)
- Reuse existing: resolve, escalate, pause, abandon, status-update
- [ ] Chat AI service: takes conversation_messages, calls Anthropic, appends response
- [ ] Auto-generate title from first message (like current assistant chat does)
- [ ] Auto-detect problem_domain from conversation
### Phase 3: Frontend — Unified Session History
- [ ] Update SessionHistoryPage to show both types
- [ ] Add type icon: compass/route for guided, message-circle for chat
- [ ] Session detail page routes correctly based on type
- [ ] Add session_type filter option
### Phase 4: Frontend — Assistant Chat on ai_sessions
- [ ] Update AssistantChatPage to use ai_sessions API instead of assistant_chats
- [ ] Chat sidebar queries ai_sessions with `session_type=chat`
- [ ] Messages read from / write to `conversation_messages`
- [ ] Add header actions: Resolve / Escalate / Share Update / Pause / Close
- [ ] Status update modal works the same as FlowPilot
### Phase 5: Frontend — Dashboard Integration
- [ ] ActiveFlowPilotSessions includes chat sessions (both types)
- [ ] RecentFlowPilotSessions includes resolved chats
- [ ] Type icon on each card so users see the difference at a glance
### Phase 6: Cleanup
- [ ] Migrate existing assistant_chat data to ai_sessions (optional — could just start fresh for pilot)
- [ ] Deprecate /assistant/* API endpoints
- [ ] Remove assistant_chats model (post-pilot)
## Visual Differentiators
| Type | Icon | Badge color | Label |
|------|------|------------|-------|
| Guided (FlowPilot) | `<Route size={14} />` | cyan | "Guided" |
| Chat (Assistant) | `<MessageCircle size={14} />` | purple/violet | "Chat" |
## API Surface (after migration)
All under `/ai-sessions`:
| Endpoint | Both types? | Notes |
|----------|------------|-------|
| `POST /ai-sessions` | Yes | `session_type` field determines behavior |
| `GET /ai-sessions` | Yes | Filter by `session_type` optional |
| `GET /ai-sessions/{id}` | Yes | Returns full session with messages or steps |
| `POST /ai-sessions/{id}/chat` | Chat only | Send/receive messages |
| `POST /ai-sessions/{id}/respond` | Guided only | Step response |
| `POST /ai-sessions/{id}/resolve` | Both | Same resolve flow |
| `POST /ai-sessions/{id}/escalate` | Both | Same escalation |
| `POST /ai-sessions/{id}/pause` | Both | Same pause |
| `POST /ai-sessions/{id}/abandon` | Both | Same abandon |
| `POST /ai-sessions/{id}/status-update` | Both | Same status updates |
## Risk Assessment
- **Low risk:** Adding columns to ai_sessions is additive, no existing data changes
- **Medium risk:** Frontend routing — need to route to correct page based on session_type
- **Data migration:** Can skip for pilot — start with fresh chat sessions on new system. Old assistant_chats remain accessible via old API until removed.
- **Rollback:** session_type column is additive, old assistant_chat endpoints can stay as fallback

View File

@@ -0,0 +1,494 @@
# Design: ResolutionFlow GTM — Escalation-Mode-First Wedge
Generated by /office-hours on 2026-04-26
Branch: main
Repo: chihlasm/resolutionflow
Status: APPROVED
Mode: Startup
## Problem Statement
ResolutionFlow is a multi-tenant SaaS troubleshooting platform for MSPs, currently
in Go-to-Market Validation (pre-PMF). The backend is feature-complete (55+ endpoints,
100+ tests, FlowPilot telemetry baseline accruing). The product has users but no
paying customers.
The blocker is not engineering completeness. The blocker is the absence of a sharp
GTM story tied to a number a buyer can verify. The session reframed the wedge twice
before landing on the real one.
**What ResolutionFlow actually is:** the structuring layer between conversational AI
and the way MSP techs work tickets. AI is great at producing answers; it is bad at
producing workflow-shaped output. ResolutionFlow gives the tech the AI they already
trust (Claude/GPT) but organizes the output into actionable structured steps,
records the session, captures customer-specific context, and turns the result into
PSA-formatted ticket notes — and optionally a runbook — without the tech writing
anything.
**Positioning line:** "the senior engineer looking over your shoulder."
## Demand Evidence
The founder is the first user. Senior Systems Engineer at an MSP, losing ~20
hours/week to cross-domain interruptions (systems engineer pulled into networking
problems and vice versa). At least 4 interruptions per day, with the time cost
concentrated in the gap between AI-conversation output and MSP-ticket workflow.
This is solving-your-own-problem demand evidence — strongest possible signal at
this stage. The 20 hrs/week figure is the founder's own time, not a hypothetical.
Every MSP shop with a senior tech and a junior tech has a version of this problem.
Telemetry signal (Phase 0.5 baseline accruing): captured flows pile up but are not
being re-used. This says capture works, retrieval doesn't — which means the
"hours-saved-via-re-use" number isn't yet generatable from existing data. The
GTM-grade ROI story needs a different metric until re-use lands: minutes recovered
per escalation, generated by Approach A below.
## Status Quo
MSP techs today resolve tickets via three workarounds:
1. **AI in a tab.** Junior tech opens Claude or ChatGPT, pastes the problem, gets a
wall of prose, parses it into action items in their head, executes, repeats. AI
does the diagnostic work. The tech does all the structure-extraction and
ticket-note-writing afterward.
2. **Tribal knowledge.** Junior tech pings senior in Slack. Senior tech is
interrupted (4+ times/day per the founder's own data). Context handoff is verbal
and lossy.
3. **Stale runbooks.** Half-maintained Notion / IT Glue / SharePoint pages that
nobody trusts because they're 18 months out of date and don't match the current
customer environment.
The cost of these workarounds for the founder personally: ~20 hours per week of
senior-tech time lost. For a 5-tech MSP, the equivalent is 1 full FTE worth of
senior-engineer hours leaking into context-switching and tab-hopping.
## Target User & Narrowest Wedge
**Target user:** Senior Systems Engineer at a small-to-mid MSP (5-20 techs). The
founder is exemplar #1. Buying authority is shared between senior tech (champion)
and MSP owner (signs the check).
**Narrowest paid wedge:** Escalation Mode. Single sharp feature. When a junior tech
escalates a ticket they were working in FlowPilot, the senior tech opens the ticket
and sees the entire structured session state — every step the junior tried, every
dead end, every command output — instead of starting with "tell me what you tried"
for five minutes.
Why this is the wedge:
- **Two metrics, not one** (revised after /codex review 2026-04-27):
- **Manual baseline** (the Assignment, weeks 0-2): senior tech stopwatches the
next 5 escalations. T1 (first diagnostic action) T0 (open ticket) under
today's verbal-handoff workflow. This is the "what you currently lose" number.
- **In-product metric** (telemetry, week 3+): time-to-first-action after claim,
derived from `ai_session_step` rows where `created_at > SessionHandoff.claimed_at`
AND `user_id = SessionHandoff.claimed_by`. This is the "what it is now with
structured handoff" number.
- **The savings claim** = manual baseline in-product metric. Quote both
explicitly in pilot conversations. Do NOT roll the in-product number alone
into "minutes recovered" — that's an apples-to-oranges miscount Codex caught
in the cross-model review.
- **Single-feature demo:** a 2-minute Loom shows the magic moment — junior hits
escalate, senior window opens with full structured context. No theory required.
- **Cross-buyer story:** sells to senior tech (less interruption) AND owner (junior
techs resolve faster, take more accounts).
- **Hours-saved math is simple:** 4-5 minutes per escalation × 15-30 escalations
per week per senior tech = 1-2 hours/week recovered per senior. At $80-150/hr
fully-loaded senior tech cost, the tool pays for itself with one customer.
## Constraints
- **One-founder shop.** Cannot run three concurrent product narratives. Sequence
matters more than scope.
- **Pre-PMF runway implied.** 4-8 week build cycles before talking to a buyer are
expensive. Approach A's 1-2 week timeline is the binding constraint.
- **Existing architecture is mostly aligned.** FlowPilot, unified_chat_service,
FlowProposal, ConnectWise PSA integration — most of the pieces exist. Risk is
positioning and UX, not capability.
- **PSA copilot competition is real.** ConnectWise / Autotask / Halo are racing to
ship AI features. The wedge has to be sharp because we lose on distribution.
## Premises
The five load-bearing claims this design rests on, all confirmed in session:
1. **Diagnostic AI is commoditized.** ResolutionFlow does not compete on
"AI solves the ticket faster." That race is over. ChatGPT/Claude already won.
2. **The structuring layer is the wedge.** AI conversational output is too dense
and unstructured for active troubleshooting. ResolutionFlow's value is
organizing that output into actionable, separable, recorded steps.
3. **Escalation context is the killer feature.** "Junior hits escalate, senior gets
full structured context in 30 seconds instead of 5 minutes" is the sharpest
demoable moment in the entire product surface.
4. **First paying customer is bottom-up, prosumer-flavored.** Senior tech at a
small MSP, $20-50/seat/month, monthly billing. Owner-targeted enterprise
pricing waits until 5+ paying shops establish baseline ROI numbers.
5. **Distribution is MSP communities, not paid SaaS ads.** r/msp, MSPGeek, RocketMSP,
PSA marketplace listings. The channel matches the buyer.
## Approaches Considered
### Approach A: Escalation Mode first (REDUCED SCOPE per /plan-eng-review)
Lead the GTM with the killer feature. Polish the escalate-with-context handoff:
junior tech mid-session hits escalate, senior tech window opens with full
structured session state. 2-min demo Loom. Pilot with **3 MSPs** in the founder's
network (capped at 3 to preserve build capacity for B). Metric: minutes recovered
per escalation.
**SCOPE REDUCTION (2026-04-27 eng review):** ~80% of Approach A is already built.
The original 2-3 week estimate assumed greenfield. Codebase audit confirms:
| What the doc said "build" | What actually exists |
|---|---|
| Session-state serialization | `ai_session.escalation_package` (JSONB), `SessionHandoff.snapshot` |
| Senior-tech inbox | [EscalationQueuePage.tsx](frontend/src/pages/EscalationQueuePage.tsx) + [EscalationQueue.tsx](frontend/src/components/flowpilot/EscalationQueue.tsx) |
| Claim workflow | [handoff_manager.py:123 claim_session()](backend/app/services/handoff_manager.py#L123) |
| API surface | [session_handoffs.py](backend/app/api/endpoints/session_handoffs.py) — POST /handoff, /claim, GET queue |
| AI assessment for senior | `_generate_ai_assessment()` in handoff_manager |
| PSA round-trip | `escalation_package_markdown`, `escalation_package_external_id` |
**Real engineering scope (~6-9 days):**
1. **Notification dual-path** (4-5 days). `notification_sent` flag is a dead column —
never written. Wire two channels in `handoff_manager.create_handoff`:
- **Email** (existing `EmailService.send_notification_email`) — handles offline seniors.
- **WebSocket / SSE push** to the EscalationQueue for live demo magic moment.
- Set `notification_sent=true` after dispatch confirmation.
- Graceful degradation: handoff still created if notification raises (regression test required).
2. **Hero metric endpoint** (~2 hours). New `GET /api/v1/analytics/escalation-metrics`,
account-scoped, role-gated to `require_engineer_or_admin`. Computes
*minutes recovered per escalation* by querying:
```
ai_session_step.created_at (first row by senior_tech_user_id where created_at > SessionHandoff.claimed_at)
minus
SessionHandoff.claimed_at
```
Returns a rolling-30-day average per account. No schema change.
3. **UX polish on EscalationQueue + receiving-engineer view** (2-3 days). Confirm the
magic-moment screen lands when senior clicks claim. Add an unread indicator on
the queue. Wire optimistic insert when SSE event arrives.
4. **Loom + landing page copy** (1-2 days). Non-engineering. Outside this plan's scope
but required for the GTM in week 3.
**Test plan:** 100% coverage of new paths — 13 tests including 4 e2e and 1 regression
(graceful-degradation when notification dispatch raises). Test plan artifact at
`~/.gstack/projects/chihlasm-resolutionflow/abc-main-eng-review-test-plan-20260427-000000.md`.
**Risk:** Low. Single feature, single metric, architecture-aligned. The dual-path
notification is the only mildly novel surface; both halves use existing infra.
**Reuses:** `services/handoff_manager.py`, `services/escalation_package_generator.py`,
`models/session_handoff.py`, `models/ai_session.py`, `services/notification_service.py`,
`models/notification_log.py`, EmailService, EscalationQueuePage + EscalationQueue.
### UI Specifications (locked by /plan-design-review 2026-04-27)
**Magic-moment screen** (new, after Pick Up click): dedicated handoff-context view that
loads BEFORE the regular FlowPilot session view, then dissolves on first senior action.
Four sections, single frame:
1. **Problem summary** (top, 2-3 lines): junior's framing. Bricolage Grotesque h2.
2. **What's been tried** (left or middle column): structured list of `dead_ends_flagged[]`
and `steps_attempted[]` from `escalation_package` JSONB. Card-flat surface, IBM Plex.
3. **AI assessment** (right column): `ai_assessment_data` rendered as 3 fields —
`likely_cause`, `suggested_steps[]`, `confidence`. accent-dim badge for confidence.
4. **Start here** (primary CTA, electric-blue, ≥44px touch target): opens FlowPilot
session at the most-likely-next-step. Senior typing or clicking anywhere triggers
200ms fade-out and FlowPilot view fades in. Re-openable via "Show handoff context"
ghost button in FlowPilot toolbar.
**Hero metric ("minutes recovered per escalation"):** lives in TWO places:
- **Queue stat-card** (above EscalationQueue list on /escalations): compact, "X.X hrs
saved this month" + "click for details" affordance. Refreshes on queue load.
- **Dedicated `/analytics/escalations` page** (owner-facing): trend chart (4-week
rolling), per-tech breakdown, per-problem-domain segmentation. Engineer-or-admin
role-gated.
**Real-time arrival visual** (when WebSocket pushes a new escalation):
- New card slides in from above the list, 200ms ease-out CSS transition.
- Browser tab title prefixes with " (1) " / " (N) " when tab is backgrounded; clears
on focus.
- No sound. MUST respect `prefers-reduced-motion: reduce` (slide-in collapses to
instant fade-in).
**Unread state:** subtle 6px dot in top-right corner of card for escalations the
current senior has never opened. Dot fades on first hover or click.
**Race-condition (two seniors click Pick Up simultaneously):** loser sees a toast
"Already claimed by [name] 2s ago" via existing `@/lib/toast`; the card flashes the
winner's name in the meta row for 1s, then dissolves from the loser's view via
optimistic update + WebSocket reconciliation.
**Unread state (Codex correction 2026-04-27):** dot indicator clears on **open,
claim, or explicit dismiss** — NOT on hover. Hover-to-clear is a bad proxy for
acknowledgment because incidental mouse movement creates false clears.
**Notification routing (Codex finding 2026-04-27):** v1 fans out the email + push
to **all engineer-or-admin role users in the same account_id as the SessionHandoff**.
No on-call/round-robin logic in v1. If pilots ask for routing, capture as v2 TODO.
The first senior to claim wins; everyone else's notification self-resolves on
WebSocket reconciliation.
**Notification delivery model (Codex correction 2026-04-27):** drop the
`notification_sent: bool` flag from v1. Replace with per-channel delivery rows
in a new `notification_log` table (already exists — reuse, don't add a new model)
keyed by `(handoff_id, channel, recipient_user_id, status)` where status ∈
{queued, sent, failed, suppressed}. This makes partial-success and per-channel
retry visible. If the existing `notification_log` schema doesn't match, defer
the per-channel persistence to a v2 TODO and v1 logs delivery attempts to the
existing telemetry stream instead. Do NOT keep the dead boolean.
**"Start here" CTA (Codex correction 2026-04-27):** opens the FlowPilot session
at the **latest known state** (the AI's most recent agent_message + the current
pending_task_lane). Surface `ai_assessment_data.suggested_steps[]` as a list of
chips below the chat input — clicking a chip prefills the input. Do NOT invent a
"jump to most-likely-next-step" capability that doesn't exist in the session model.
**`/claim` role gate (Codex correction 2026-04-27, IN-SCOPE for v1):** add
`require_engineer_or_admin` dep on POST `/handoffs/{id}/claim`. Originally
deferred to TODO during eng review; Codex correctly flagged it as wedge-relevant
because the race-condition story depends on auth gating. ~30 min change. Removed
from TODO.md.
**A11y requirements (mandatory before pilot ship):**
- Keyboard: Tab order through queue cards; Enter on focused card opens it; Pick Up
button is a reachable target; Esc closes the handoff-context overlay.
- ARIA: `role="region"` + `aria-live="polite"` on the queue list (announces arrivals);
`aria-label="N escalations awaiting pickup"` on the heading; the slide-in animation
must not announce twice (debounce live-region updates).
- Pick Up button: bump from `py-2` to `py-2.5` to clear the 44px touch-target floor.
- Color contrast: confidence-badge text on accent-dim background must be ≥4.5:1
(verify against DESIGN-SYSTEM.md tokens).
**DS token discipline:** every new piece must use `card-flat`, `accent-dim`/`accent-text`,
`text-muted-foreground`, `bg-card`/`bg-elevated`, IBM Plex / Bricolage / JetBrains,
explicit `transition` property lists (never `transition: all`). No glass, no blur,
no gradient surfaces. Electric-blue accent reserved for interactive elements only.
**Mobile responsive:** deferred to post-pilot TODO. Pre-PMF wedge target is desktop;
MSP techs work on laptops/desktops in shop environments.
**Deferred to TODO.md (out of scope for v1 wedge):**
- Peer-tech escalates colleague's session (currently session-owner-only)
- Role gate on POST /claim (currently any authenticated user in account)
### Approach B: Full Structured Resolution loop (split B1 + B2)
End-to-end demo: tech opens FlowPilot, structure appears in side panel as AI
responds, ticket notes auto-populate at end, optional runbook capture for reusable
patterns. Tells the full "senior engineer over your shoulder" story.
**B1 — Side panel + PSA-formatted ticket notes** (ships first):
- Structured side panel that surfaces parsed AI markers as live actionable steps
while the conversation runs.
- PSA-formatted ticket-notes exporter (ConnectWise first; Autotask/Halo later).
- Effort: M (~3 weeks).
**B2 — Runbook offer-and-save** (gated on pilot demand):
- "Save this resolution as a flow?" prompt at session end, with auto-drafted
runbook from the structured session state.
- Effort: S (~1 week). Don't build until at least 2 pilot customers explicitly
ask for it.
- **Risk:** Medium. The structured-output panel quality is the whole demo. If it
looks dumb, the demo dies.
- **Reuses:** FlowPilot, unified_chat_service, FlowProposal, ConnectWise PSA
integration.
### Approach C: Senior-Tech Time-Saved Counter
Continuous measurement layer underneath A and B. Every session contributes an
estimated minutes-saved number. Owner-facing dashboard quotes "this month your
shop saved N hours of senior-tech time." Sells to MSP owner with verifiable ROI.
- **Effort:** S (~1 week + ongoing measurement methodology refinement).
- **Risk:** Medium-low. Methodology has to be defensible. If numbers look
made-up, trust dies fast.
- **Reuses:** FlowPilot telemetry, session metadata, account-scoped analytics.
## Recommended Approach
**A first (1-2 weeks), then B (3-4 weeks after A ships), with C running underneath
both as a continuous backdrop.**
Sequence rationale:
- **A is the sharpest possible 2-minute demo.** Single feature, single metric,
buyer-verifiable in their own data. Get it in front of 5 MSPs in week 3.
- **B is the depth play.** Once Approach A has produced first-pilot signal,
Approach B's full structured-resolution loop becomes the "what we ship next" that
retains pilots and converts them to paid.
- **C compounds across both.** Every session under A or B contributes to the
time-saved counter. By week 6 there are real numbers to put in front of an MSP
owner — turning a senior-tech-led pilot into an owner-signed contract.
This sequence is non-negotiable. Building B before A is the classic pre-PMF trap of
perfecting product before validating GTM. Building C alone is measurement without a
demo to anchor it.
## Pricing
**Pilot pricing (first 3-5 customers): $39/seat/month, monthly billing,
month-to-month.** Anchored against IT Glue (~$29/tech), Hudu (~$25/tech),
Liongard (~$3/endpoint). The premium over IT Glue/Hudu reflects the active-session
value (vs. their static-runbook value) — 30% above the runbook-only category.
Customer #6+ pricing is an Open Question (revisit after 3 pilots produce real
hours-saved data; price up if the per-seat ROI is over $200/seat/mo).
## Open Questions
1. **Free-tier shape.** Should the time-saved counter be free forever as a
distribution lever, with paid for the structuring + escalation? Land-and-expand
pattern. Decide after 3 pilot conversions.
2. **PSA-marketplace timing.** ConnectWise Marketplace listing requires partnership
onboarding (~6-week cycle). Submit application week 5; expect listing live by
week 11. Don't gate launch on it.
3. **Customer #6+ pricing.** Revisit after 3 pilot customers produce verifiable
hours-saved numbers.
## Deferred (YAGNI until 10 paying customers)
- HIPAA / SOC2 audit positioning. Pre-PMF is too early; revisit when a regulated-
vertical MSP asks for it explicitly.
- Multi-PSA depth (Autotask, Halo). ConnectWise alone covers ~40% of the SMB MSP
market and is sufficient for first 5-10 customers.
- Cross-tenant pattern detection. The data-flywheel-across-shops play is at least
6 months out; building it before single-shop ROI is proven is premature.
## Success Criteria (revised for realism)
- **Week 3:** Approach A shipped. 3 MSPs in active free pilot (cap at 3 to
preserve B1 build capacity).
- **Weeks 3-6:** Pilot management dominates. B1 build is paused; founder runs
pilot calls, captures bug reports, iterates UX. Stripe seat-based billing is
set up in week 5.
- **Week 6:** First verbal commit from a pilot customer. Verified
minutes-recovered-per-escalation number from at least 2 pilots.
- **Week 8:** First paid customer (procurement cycles run 4-6 weeks even at small
MSPs; 2 weeks from verbal commit to signed contract is realistic). Time-saved
counter (Approach C) producing dashboard-quality data.
- **Week 11:** B1 (side panel + PSA notes) shipped. 3-5 paying customers. First
MSP-owner-led conversation. ConnectWise Marketplace listing live.
- **Quarter end:** $5K MRR or 10 paying customers, whichever comes first. Loom
demos posted publicly to r/msp and MSPGeek.
## Distribution Plan (week-by-week cadence)
- **Week 3:** Escalation Mode demo Loom posted. r/msp launch post.
- **Week 4:** MSPGeek Discord AMA scheduled. RocketMSP newsletter pitch sent.
- **Week 5:** ConnectWise Marketplace listing application submitted. Stripe
billing live for paid conversion.
- **Week 6:** First "guest on Inside MSP podcast" outreach. Second r/msp post
(case study from a pilot, anonymized).
- **Week 7-8:** Pilot conversion calls. First paying customer.
- **Week 9-11:** B1 ships. Owner-targeted demo Loom. Second podcast outreach.
**Founder-led pilot:** The first 3-5 customers come from the founder's existing
MSP network. Treat them as design partners; expect to ship feature requests
weekly during pilot. Cap at 3 active pilots until B1 ships.
**Tech audience channels:** r/msp, r/sysadmin, MSPGeek Discord, RocketMSP
newsletter, Inside MSP podcast.
**Owner audience channels:** ConnectWise Marketplace, MSP-focused Substacks,
RIA Vendor Roundup.
CI/CD: existing Railway auto-deploy via GitHub mirror. No new pipeline needed.
## Dependencies
- **Session-state serialization (Approach A blocker).** Schema design + migration
is the longest-lead engineering task. 3-5 days budget. Do this first.
- **Stripe seat-based billing (week 5 task).** No billing infrastructure exists
today. ~3-5 days of work for monthly subscriptions + invoice flow. Block on
this before week-8 first-paid milestone.
- **ConnectWise PSA integration depth.** Sufficient for ticket-notes auto-export
(Approach B1). Autotask and Halo wait until first 5 paying ConnectWise
customers.
- **Authentication.** Existing JWT + role hierarchy is sufficient for senior-tech
inbox view; no new auth work needed.
## Risks and Kill-Switch
- **Risk: Session-state serialization design churn.** If the schema needs to
change after pilot feedback, every saved session has to migrate. Mitigation:
keep schema versioned and forward-compatible from day 1.
- **Risk: Pilot-to-paid conversion slower than 4-6 weeks.** MSP procurement is
notoriously slow. Mitigation: get verbal commits in writing; price as
month-to-month with no annual contract to lower the buying friction.
- **Risk: ConnectWise ships an equivalent feature in their 2026.x release.**
Mitigation: lead the marketing on "we're independent of your PSA" — works with
any PSA, not just ConnectWise. The founder's PSA-agnostic FlowPilot is an
asset here.
- **Kill-switch criterion:** if 0 of 3 pilots produce a verifiable
hours-saved-per-week number above 1.0 by week 8, **revisit the wedge**. The
product may need to pivot to deterministic-ops territory (Read 1 from the
session) or be repositioned. Don't sink another quarter into the current GTM
story without this number.
## The Assignment
**This week, before any code:**
Time-track the next 5 escalations in your shop manually. For each, capture:
1. Time the senior tech opens the ticket
2. Time the senior tech takes their first diagnostic action (not counting the
verbal "tell me what you tried" warm-up)
3. The delta — that's the wasted time per escalation today
Average those 5 numbers. **That's the hero stat in your first sales conversation:**
"Senior techs at our shop wasted N minutes per escalation just getting up to
speed. We built the thing that takes that to zero."
Don't try to pull this from telemetry — the doc itself notes that retrieval/re-use
data isn't queryable yet. Manual stopwatch on the next 5 escalations is the
fastest path to a defensible number.
This is the assignment because it forces the GTM story into the same time-zone as
the build, and it's a one-day effort that compounds for every conversation
afterward.
## What I noticed about how you think
- You contradicted my framing twice in the same session and the second
contradiction was sharper than the first. Most founders agree with the
diagnostic and walk out with a polished version of what they came in with. You
said "I'm just questioning if flows are even the way to go" — and that
sentence reset the entire wedge. That's craft.
- "The senior engineer looking over your shoulder" came out of you spontaneously,
not as a prepared pitch. That's the line. Use it. It survives because it's
emotional truth (every junior tech has had this, every senior tech has been
this), not constructed marketing copy.
- You're solving your own problem with your own time. 20 hrs/week isn't a
hypothetical user pain — it's your Tuesday. Founders who solve their own pain
ship sharper products because the feedback loop is instant.
- The escalation feature emerged from your description, not mine. I was busy
cataloging documentation pains. You said "junior to senior escalation? no
worries there either" almost as an afterthought. That afterthought is the wedge.
Pay attention to which features you describe casually versus which you push hard
on — the casual ones are sometimes where the truth lives.
## GSTACK REVIEW REPORT
| Review | Trigger | Why | Runs | Status | Findings |
|--------|---------|-----|------|--------|----------|
| CEO Review | `/plan-ceo-review` | Scope & strategy | 0 | — | not run |
| Codex Review | `/codex review` | Independent 2nd opinion | 1 | INFO | 12 findings, 6 applied, 1 partial, 5 rejected |
| Eng Review | `/plan-eng-review` | Architecture & tests (required) | 1 | CLEAR (PLAN) | 2 issues, 0 critical gaps, scope reduced |
| Design Review | `/plan-design-review` | UI/UX gaps | 1 | CLEAR (FULL) | score 6/10 → 9/10, 8 decisions |
| DX Review | `/plan-devex-review` | Developer experience gaps | 0 | — | not run |
- **CODEX:** 12 findings reviewed. Applied: 2-metric framing (#2), notification routing spec (#3), per-channel delivery model (#4), unread-state fix (#11), Start-here CTA reframe (#9), claim role gate moved in-scope (#8). Rejected: full scope reduction to PSA-brief-only (#6/7/12 — user kept queue UI as demo hero). Partial: scope concern (#5) acknowledged in eng review's email-first/polling-fallback. Misread: #1, #10.
- **CROSS-MODEL:** Claude (eng + design reviews) and Codex agree on 6/12 findings. The major disagreement was scope — Codex argued for cutting the queue UI, user rejected. Both agree on metric definition, notification routing, claim auth gating.
- **UNRESOLVED:** 0
- **VERDICT:** ENG + DESIGN CLEARED, CODEX REVIEWED — ready to implement.

View File

@@ -0,0 +1,33 @@
# Test Plan
Generated by /plan-eng-review on 2026-04-27
Branch: main
Repo: chihlasm/resolutionflow
## Affected Pages/Routes
- `/escalations` ([EscalationQueuePage.tsx](frontend/src/pages/EscalationQueuePage.tsx)) — senior-tech inbox view; verify queue list, real-time arrival, click-through
- `/pilot/:session_id` (FlowPilotSessionPage) — verify post-claim load shows full escalation context (snapshot, ai_assessment, escalation_package)
- `GET /api/v1/analytics/escalation-metrics` (NEW) — verify hero metric calculation, account-scoping, role gate
## Key Interactions to Verify
- Junior tech clicks **Escalate** in active FlowPilot session → handoff is created → notification fires → senior sees escalation in queue within 30 seconds
- Senior tech clicks **Claim** in queue → session reactivates → senior is redirected into FlowPilot session view → ai_assessment + snapshot are visible
- Senior types first message in chat after claim → metric query starts attributing time-to-first-action
- MSP owner opens analytics page → "minutes recovered per escalation" widget shows current month's rolling average
## Edge Cases
- **Two seniors race to claim** the same handoff → one wins, the other gets a "Already claimed by [name]" message
- **Senior is offline** when escalation fires → email arrives via existing `EmailService.send_notification_email`
- **WebSocket disconnects mid-session** → frontend reconnects; missed events backfilled by re-fetching the queue
- **Notification dispatch raises** (SMTP down, WebSocket fanout fails) → handoff is still created (graceful degradation)
- **Senior takes non-chat action first** (e.g., posts directly to PSA) → metric falls back to PSA writeback timestamp or remains null; doc the chosen behavior
- **Account-scoped multi-tenancy** → senior at MSP A cannot see escalations from MSP B (Phase 4 RLS)
- **Role gate on metric endpoint** → only `engineer_or_admin` can hit `/escalation-metrics`
## Critical Paths
1. **Magic-moment demo flow** (the entire Loom): junior escalate → senior notification → senior claim → session view → first action recorded → metric updates
2. **Email fallback** when senior is offline — must not silently drop
3. **Regression: handoff creation succeeds even if notification dispatch raises** — graceful degradation is mandatory