feat: FlowPilot AI — Phases 4 & 5 (Gallery, Export, Responsive, Enterprise, Analytics) #116

Merged
chihlasm merged 66 commits from feat/flowpilot-ai-session into main 2026-03-21 05:15:51 +00:00
177 changed files with 29721 additions and 851 deletions

View File

@@ -23,7 +23,7 @@
- **Fonts:** Bricolage Grotesque (`font-heading`, headings/titles), IBM Plex Sans (`font-sans`, body text), JetBrains Mono (`font-label`, labels/badges/timestamps) — loaded via Google Fonts - **Fonts:** Bricolage Grotesque (`font-heading`, headings/titles), IBM Plex Sans (`font-sans`, body text), JetBrains Mono (`font-label`, labels/badges/timestamps) — loaded via Google Fonts
- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with cyan gradient). Wordmark: "Resolution" in `text-foreground` + "Flow" in `text-gradient-brand` - **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with cyan gradient). Wordmark: "Resolution" in `text-foreground` + "Flow" in `text-gradient-brand`
- **Brand assets:** `brand-assets/` (source SVGs + brand-guide.html), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon) - **Brand assets:** `brand-assets/` (source SVGs + brand-guide.html), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
- **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `tailwind.config.js` and `index.css`). Glass utilities: `.glass-card` (interactive, `scale(1.02)` hover), `.glass-card-static` (no hover transform), `.active-glow` (breathing cyan shadow) - **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `index.css` via `@theme`). Glass utilities: `.glass-card` (interactive, `scale(1.02)` hover), `.glass-card-static` (no hover transform), `.active-glow` (breathing cyan shadow)
- **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). Two fixed atmosphere orbs (cyan top-right, purple bottom-left) behind the shell for ambient glow. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) - **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). Two fixed atmosphere orbs (cyan top-right, purple bottom-left) behind the shell for ambient glow. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Navigation:** Sidebar nav with type sub-items (All Flows → Troubleshooting / Projects / Maintenance). Pinned flows section for quick access. NO workspace switcher. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) - **Navigation:** Sidebar nav with type sub-items (All Flows → Troubleshooting / Projects / Maintenance). Pinned flows section for quick access. NO workspace switcher. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Maintenance flows are called "Maintenance" in the UI. `tree_type` column values unchanged in DB. - **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Maintenance flows are called "Maintenance" in the UI. `tree_type` column values unchanged in DB.
@@ -62,9 +62,11 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
### What's In Progress ### What's In Progress
- ConnectWise PSA Integration (ticket linking, note posting, member mapping, status updates) - ConnectWise PSA Integration (ticket linking, note posting, member mapping, status updates)
- Knowledge Flywheel (Phase 3): AI analysis of FlowPilot sessions → flow proposals, review queue, analytics dashboard
### Recently Completed ### Recently Completed
- FlowPilot Phase 2: PSA integration, escalation handoff, session pause/resume, mid-session ticket linking
- Step Library Foundation - Step Library Foundation
- AI chat session conclusion: outcome tracking, AI-generated ticket summaries, resume flow - AI chat session conclusion: outcome tracking, AI-generated ticket summaries, resume flow
- Survey completion: email-to-self, thank-you page, admin read/unread/archive/delete management - Survey completion: email-to-self, thank-you page, admin read/unread/archive/delete management
@@ -91,7 +93,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
### Frontend ### Frontend
- **Framework:** React 19 + Vite + TypeScript - **Framework:** React 19 + Vite + TypeScript
- **Styling:** Tailwind CSS v3 — dark-first with purple gradient accents (see Branding section) - **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — dark-first with ice-cyan gradient accents (see Branding section)
- **State:** Zustand (with immer + zundo for undo/redo) - **State:** Zustand (with immer + zundo for undo/redo)
- **Routing:** React Router v7 - **Routing:** React Router v7
- **API Client:** Axios with token refresh interceptor - **API Client:** Axios with token refresh interceptor
@@ -107,24 +109,29 @@ patherly/
│ ├── app/ │ ├── app/
│ │ ├── main.py # FastAPI entry point │ │ ├── main.py # FastAPI entry point
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, psa_connections) │ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, psa_connections)
│ │ ├── api/deps.py # Auth dependencies │ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
│ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
│ │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
│ │ ├── api/router.py # Route registration │ │ ├── api/router.py # Route registration
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit │ │ ├── core/ # config, database, permissions, security, audit, rate_limit
│ │ ├── models/ # SQLAlchemy models │ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
│ │ ├── schemas/ # Pydantic schemas │ │ ├── schemas/ # Pydantic schemas
│ │ ── services/psa/ # PSA provider abstraction (base, connectwise/, cache, encryption, registry, types) │ │ ── services/psa/ # PSA provider abstraction (base, connectwise/, cache, encryption, registry, types)
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
│ ├── alembic/ # Database migrations (001-029+) │ ├── alembic/ # Database migrations (001-029+)
│ ├── scripts/ # seed_data.py, seed_trees.py │ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests │ └── tests/ # pytest integration tests
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
│ │ ├── api/ # Axios client + endpoint modules │ │ ├── api/ # Axios client + endpoint modules
│ │ ├── components/ # common, layout, tree-editor, session, procedural, procedural-editor, library, step-library, ui │ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts │ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
│ │ ├── pages/ # All page components │ │ ├── pages/ # All page components
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences) │ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences)
│ │ └── types/ # TypeScript interfaces │ │ └── types/ # TypeScript interfaces
│ └── tailwind.config.js │ └── (Tailwind v4: CSS-only config in src/index.css)
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026) ├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
├── CLAUDE.md # This file ├── CLAUDE.md # This file
├── CURRENT-STATE.md # Detailed feature status ├── CURRENT-STATE.md # Detailed feature status
@@ -192,7 +199,7 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request - Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url` - `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
- All PSA integration code in `services/psa/` — provider pattern with `BasePsaProvider` abstract class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch - All PSA integration code in `services/psa/` — provider pattern with `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
- PSA endpoints in `api/endpoints/psa_connections.py` — connection CRUD, ticket ops, member mapping - PSA endpoints in `api/endpoints/psa_connections.py` — connection CRUD, ticket ops, member mapping
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet) - Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user - Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
@@ -313,6 +320,28 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**66. Dev environment runs on devserver01 (192.168.0.9), not localhost:** Code-server runs in Docker on a LAN server. Frontend/backend are accessed via `192.168.0.9`, not `localhost`. CORS must include `http://192.168.0.9:5173` in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL=http://192.168.0.9:8000`. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues. **66. Dev environment runs on devserver01 (192.168.0.9), not localhost:** Code-server runs in Docker on a LAN server. Frontend/backend are accessed via `192.168.0.9`, not `localhost`. CORS must include `http://192.168.0.9:5173` in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL=http://192.168.0.9:8000`. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Check `router.tsx` line 156 for the canonical path. Use `getTreeEditorPath()` from `@/lib/routing` when navigating programmatically.
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping scheduler runs can process the same records twice (TOCTOU race). Always set `max_instances=1` on interval jobs in `main.py`.
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields, or JSON serialization may produce unexpected types.
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — they require `modified_flow_data` via "Edit & Publish" flow. Only `new_flow` proposals support direct approve.
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). If adding new status values, verify length. Migration `f0aad74ea51b` widened from 20→30.
**73. `get_db` rolls back on exception:** The dependency does `await session.rollback()` on error to prevent `InFailedSQLTransaction` cascade. Never remove this — without it, one failed request poisons subsequent requests on the same connection.
**74. FlowPilot action bar height chain:** The action bar (Resolve/Escalate/Pause) requires every ancestor from `app-shell` grid down to have proper flex constraints. Key fix: `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, check height chain with DevTools `getBoundingClientRect()` walk.
**75. Dashboard prefill auto-submits:** `StartSessionInput` navigates to `/pilot` or `/assistant` with `{ state: { prefill } }`. `FlowPilotSessionPage` auto-submits via `useEffect` + `prefillHandledRef` guard — no double-enter. `AssistantChatPage` does the same pattern.
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` (same as `TreeEditorPage`) to intercept navigation during active sessions. "Pause & Leave" auto-pauses before proceeding.
**77. Prefer manual Alembic migrations for targeted changes:** `alembic revision --autogenerate` picks up drift from all tables. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
--- ---
## RBAC & Permissions ## RBAC & Permissions
@@ -353,6 +382,8 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
- **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components. - **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components.
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation. - **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
- **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`. - **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`.
- **Dashboard cockpit:** `QuickStartPage` is the FlowPilot launchpad. Components in `components/dashboard/`: `StartSessionInput` (mode picker: guided/chat), `PendingEscalations`, `ActiveFlowPilotSessions`, `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`, `RecentFlowPilotSessions`. Every stat/card navigates to its detail page on click.
- **Sidebar sections:** Dashboard → RESOLVE (Active Sessions, Escalations) → KNOWLEDGE (Flows, Step Library, Scripts, Review Queue) → INSIGHTS (Exports, Analytics, FlowPilot Analytics). Footer: User Guides, Feedback, Account, Collapse.
--- ---

View File

@@ -2,19 +2,19 @@
> **Purpose:** Quick-reference file showing exactly where the project stands. > **Purpose:** Quick-reference file showing exactly where the project stands.
> **For Claude Code:** Read this first to understand what's done and what's next. > **For Claude Code:** Read this first to understand what's done and what's next.
> **Last Updated:** February 14, 2026 > **Last Updated:** March 19, 2026
--- ---
## Active Phase: Phase 2.5 - Step Library Foundation (In Progress) ## Active Phase: Search & Evidence Features (Complete)
--- ---
## What's Complete ## What's Complete
### Backend (100%) ### Core Platform
- FastAPI project structure with 25+ API endpoints - FastAPI project structure with 35+ API endpoints
- PostgreSQL database with Docker, 30+ Alembic migrations - PostgreSQL database with Docker, 75+ Alembic migrations
- User authentication (JWT, register, login, refresh, logout, invite codes) - User authentication (JWT, register, login, refresh, logout, invite codes)
- Refresh token rotation with JTI-based revocation - Refresh token rotation with JTI-based revocation
- Trees CRUD with full-text search (FTS index) - Trees CRUD with full-text search (FTS index)
@@ -27,32 +27,127 @@
- Audit log table with JSONB details - Audit log table with JSONB details
- Soft delete for trees with cascade cleanup - Soft delete for trees with cascade cleanup
### Frontend (Phase 2 Complete) ### Frontend Core
- React 19 + Vite + TypeScript + Tailwind setup - React 19 + Vite + TypeScript + Tailwind CSS v4 (`@tailwindcss/vite`)
- Authentication UI (login, register) - **Slate & Ice Design System** — Dark glassmorphism, ice-cyan gradient accents, glass-card system
- **Brand fonts:** Bricolage Grotesque (headings), IBM Plex Sans (body), JetBrains Mono (labels)
- Authentication UI (login, register, email verification)
- Tree library/browsing page with grid/list/table views - Tree library/browsing page with grid/list/table views
- Tree navigation interface (session player) - Tree navigation interface (session player)
- Session management with history and detail pages - Session management with history and detail pages
- Export functionality (download)
- **Tree Editor** — Form-based with visual preview, Zustand + immer + zundo (undo/redo) - **Tree Editor** — Form-based with visual preview, Zustand + immer + zundo (undo/redo)
- **Markdown rendering** in session player and node editor - **Markdown rendering** in session player and node editor
- **Monochrome Design System** — Dark-only, glass-morphism cards, Inter font, theme toggle removed
- **Tree Organization** — Categories, tags (autocomplete), user folders (3-level hierarchy), filters - **Tree Organization** — Categories, tags (autocomplete), user folders (3-level hierarchy), filters
- **RBAC & Permissions** — `usePermissions` hook, ProtectedRoute with role guards, permission-based UI hiding - **RBAC & Permissions** — `usePermissions` hook, ProtectedRoute with role guards
- **Session Scratchpad** — Floating overlay (Ctrl+/), auto-save, markdown preview - **Session Scratchpad** — Floating overlay (Ctrl+/), auto-save, markdown preview
- **Admin Panel** — 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories) - **Admin Panel** — 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories)
- **Session Quick Wins** (Issues #51-#55): - **Session Quick Wins** — Timer, keyboard hints, repeat last, auto-recovery, copy step, delete tree
- Session timer (`useSessionTimer` hook, MM:SS / HH:MM:SS) - **Session Outcomes** — Outcome modal on completion, step timing tracking
- Keyboard hints (Tab focuses notes) - **Session Sharing** — Share links, public/account views, MySharesPage
- Repeat Last Session (prefills metadata from localStorage) - **Procedural Editor UX** — Section headers, collapsible advanced fields, URL intake, tag input
- Session auto-recovery (resume incomplete sessions) - **Type-aware Routing** — Centralized `getTreeNavigatePath`/`getTreeEditorPath` helpers
- Copy step to clipboard - **Account Management** — Profile settings, delete/leave/transfer, chat retention
- Delete tree button in all view modes - **PostHog Analytics** — Event tracking, user identification, autocapture
- **Session Outcomes** — Outcome modal on session completion, step timing tracking
- **Settings page** at `/settings` — Default export format preference ### FlowPilot AI System (Phases 1-3 Complete)
- **Session Sharing** — ShareSessionModal, SharedSessionPage (`/shared/sessions/:token`), MySharesPage (`/my-shares`), share link copy/manage from navigation page
- **Procedural Editor UX** — Section headers as first-class step type, "More Options" collapsible for advanced fields, URL intake field type, improved tag input (comma/semicolon/Tab delimiters) **Phase 1 — AI Session Engine:**
- **Type-aware Routing** — Centralized `getTreeNavigatePath` helper, procedural sessions route to `/flows/:id/navigate`, resume support in procedural navigator, safety redirect in troubleshooting navigator - FlowPilotEngine with multi-step guided troubleshooting
- AI copilot panel + standalone assistant chat with RAG
- Confidence-tiered model routing via `settings.get_model_for_action()`
- Intake form with ticket/client fields, session pause/resume
- AI-generated ticket summaries, outcome tracking
**Phase 2 — PSA Integration & Escalation:**
- ConnectWise PSA integration (ticket linking, note posting, member mapping)
- PSA documentation auto-push with retry scheduler
- Session pause/resume, mid-session ticket linking
- Escalation handoff workflow with LLM-enhanced briefing package
- Escalation pickup flow for senior engineers
- PSA settings UI on IntegrationsPage
- In-session script generator
**Phase 3 — Knowledge Flywheel:**
- AI session analysis → automatic flow proposal generation
- FlowProposal model with review queue (approve, edit & publish, dismiss, reject)
- Knowledge gap detection (weak options, high escalation domains)
- FlowPilot analytics dashboard (metrics, confidence tiers, PSA stats, gaps)
- APScheduler batch analysis job with `max_instances=1`
- Auto-reinforcement for sessions matching existing flows
### Phase 4 — Enterprise & Growth Features (All Slices Complete)
**Slice 1 — Public Templates Gallery:**
- Public API endpoints (no auth): gallery listing, flow/script detail, categories, search
- `is_gallery_featured` and `gallery_sort_order` columns on trees and script_templates
- IP-based rate limiting (30/min), tree structure truncated to 3 levels (signup wall)
- Public `/templates` page with hero, search, category filters, responsive card grid
- Detail modal with tree preview or parameter list + signup CTA
- Admin gallery curation page (feature toggle, sort order)
- 25 backend tests
**Slice 2 — Notification System:**
- NotificationConfig, NotificationLog, Notification models + migration
- Multi-channel delivery: in-app, email (Resend), Slack webhooks, Teams webhooks
- Notification service with event routing and fire-and-forget delivery
- APScheduler retry job with exponential backoff (30s, 2m, 10m, max 3 retries)
- 9 API endpoints (config CRUD + in-app notification management)
- Wired into escalation, proposal approval, and knowledge flywheel events
- Frontend: NotificationsPanel (bell icon + dropdown), NotificationSettings UI
**Slice 3 — Session Export (Polish):**
- 5-format export already existed (markdown, text, HTML, PSA, PDF via WeasyPrint)
- Added "Generated with ResolutionFlow" branding footer to all 5 formats
- Fixed PDF template conditional that was hiding branding
- Added spinner for PDF generation loading state
**Slice 4 — Mobile/Responsive:**
- Responsive audit pass across 11 FlowPilot and analytics components
- FlowPilotSession: collapsible mobile sidebar, single-column layout on mobile
- Action bars: full-width stacked buttons on mobile, 44px touch targets
- Modals: full-width slide-up pattern on mobile
- ReviewQueuePage: stacked panels on mobile
- Analytics: single-column chart stack on mobile
**Slice 5 — Enterprise Readiness:**
- Custom branding: logo URL, primary accent color, company name (owner-only)
- CSS variable overrides applied in app shell for accent color
- Branding settings page under Account Settings
- Autotask PSA and Halo PSA stub providers (Coming Soon badges in UI)
- SSO/SAML groundwork: sso_enabled, sso_provider, sso_config columns on Account
- SSO stub service with interface methods
- "Contact us to enable SSO" section in Account Settings
### Phase 5 — Analytics Enhancement (Complete)
- Tabbed analytics page: Overview, Coverage, Flow Quality, PSA
- Coverage heatmap: domain grid with color-coded cells (resolution/escalation/guided rates, flow count)
- Domain-to-flow mapping via category cross-reference
- Flow quality scoring endpoint: quality_score = (success_rate * 0.5) + (guided_rate * 0.3) + (recency * 0.2)
- Flow quality table: sortable, top performers (emerald), needs attention (rose), mini score bars
- Flow usage tracking: usage_count, success_rate, last_matched_at wired into session matching + resolution
- PSA activity logging: psa_activity_logs table, wired into documentation push service
- Enhanced PSA metrics: time entries, hours logged, push success funnel, daily trend chart
- 13 new backend tests for coverage and flow quality endpoints
### Search & Recall + Evidence-Rich Sessions (Complete)
**Evidence:**
- Railway Object Storage (S3-compatible) integration via boto3
- file_uploads model with upload/download/list/delete API endpoints
- RichTextInput component: clipboard paste (Ctrl+V) and drag-and-drop for images
- Wired into FlowPilot intake, free-text responses, and escalation modal
- Evidence included in all 5 export formats (markdown, text, HTML, PSA, PDF)
- 15 backend tests for upload endpoints
**Search:**
- Structured filters on AI sessions: problem_domain, matched_flow, confidence_tier, ticket_id, date range
- Filter bar UI on Session History page (AI Sessions tab)
- PostgreSQL full-text search via generated tsvector column + GIN index on ai_sessions
- Command Palette extended with AI session search results
- Voyage AI semantic embeddings on ai_session_embeddings table (pgvector cosine similarity)
- Similar sessions endpoint: GET /ai-sessions/{id}/similar
- Similar Sessions sidebar component in FlowPilot session view
### Security Hardening (Phases A-D Complete) ### Security Hardening (Phases A-D Complete)
- Registration role hardcoded to `engineer` - Registration role hardcoded to `engineer`
@@ -63,72 +158,76 @@
- Centralized permissions in `permissions.py` - Centralized permissions in `permissions.py`
- `is_active` field on User model, enforced in auth - `is_active` field on User model, enforced in auth
- Admin user management endpoints (6 endpoints) - Admin user management endpoints (6 endpoints)
- Refresh token rotation with JTI-based revocation
- Password complexity validation (uppercase, lowercase, digit, min 10 chars) - Password complexity validation (uppercase, lowercase, digit, min 10 chars)
- Soft delete cascade cleanup (folder/tag junctions) - Soft delete cascade cleanup (folder/tag junctions)
- SQL wildcard escaping in tag search - SQL wildcard escaping in tag search
- PSA credentials encrypted at rest (Fernet)
### Backend Schema Features (Not Yet in Frontend) ### Maintenance Flows
- **Tree Forking** (migration 022) — `parent_tree_id`, `root_tree_id`, `fork_depth`, `fork_reason` - Batch session launch, saved target lists
- **Tree Sharing** (migration 024) — tree share links - APScheduler scheduling with croniter + pytz
- **Enhanced Invite Codes** (migration 030) — email, assigned_plan, trial_duration_days
### Survey System
- Public survey page, admin invite tracking
- Response viewer with CSV export
- Email-to-self, thank-you page
- Admin read/unread/archive/delete management
### Documentation ### Documentation
- CLAUDE.md (project context for Claude Code) - CLAUDE.md (comprehensive project context)
- CLAUDE.md includes consolidated lessons learned (formerly LESSONS-LEARNED.md) - UI-DESIGN-SYSTEM.md, REBRAND-IMPLEMENTATION-GUIDE.md
- Design system guide, component examples - ConnectWise API reference docs in `docs/connectwise/`
- Feature specifications through Phase 4 - Feature specifications through Phase 4
- Rebrand implementation guide - Phase implementation plans in `docs/plans/`
--- ---
## What's In Progress ## What's In Progress
| Task | Status | Notes | No active slices — Phase 5 complete.
|------|--------|-------|
| Editor-Embedded Flow Assist | In Progress | AI panel in tree + procedural editors, ghost node suggestions, action-type routing |
| Step Library Frontend | In Progress | Backend complete, frontend UI pending |
| Procedural Flows Lifecycle | In Progress | Resume support done, full run chooser/reuse pending |
| Tree Forking UI | Planning | Backend schema complete (migration 022) |
--- ---
## What's Next (Priority Order) ## What's Next (Priority Order)
### Immediate (Phase 2.5 Completion) ### Soon (Phase 6+)
1. Step Library Frontend UI (browse, search, rate/review)
2. Procedural Flows run lifecycle (RunChooserModal, intake reuse/prefill)
3. Tree Forking UI and workflow
### Soon (Phase 3) - Full Autotask PSA implementation
- File attachments for sessions - Full Halo PSA implementation
- Offline capability - Full SSO/SAML implementation (SAML + OIDC flows)
- Client context system - Dedicated Insights dashboard (strategic metrics for team leads, separate from operational analytics)
- Advanced analytics dashboard
### Later (Phase 4)
- PSA integrations (ConnectWise, Kaseya)
- PowerShell automation framework - PowerShell automation framework
- Enterprise features (SSO, white-label)
### Later
- White-label deployment
- Marketplace for community flow templates
- Native mobile app (React Native or PWA)
--- ---
## Environment Quick Reference ## Environment Quick Reference
### Start Development ### Start Development
```powershell ```bash
docker start patherly_postgres # Start PostgreSQL (Docker Compose)
cd backend && .\venv\Scripts\activate && uvicorn app.main:app --reload docker compose up -d
cd frontend && npm run dev
# Backend (from backend/)
source venv/bin/activate
uvicorn app.main:app --reload
# Frontend (from frontend/)
npm run dev
``` ```
### URLs ### URLs
- Frontend: http://localhost:5173 - Frontend: http://192.168.0.9:5173
- Backend API: http://localhost:8000 - Backend API: http://192.168.0.9:8000
- API Docs: http://localhost:8000/api/docs - API Docs: http://192.168.0.9:8000/api/docs
### Run Tests ### Run Tests
```powershell ```bash
cd backend && pytest --override-ini="addopts=" cd backend && pytest --override-ini="addopts="
``` ```
@@ -138,5 +237,6 @@ cd backend && pytest --override-ini="addopts="
| Issue | Workaround | Status | | Issue | Workaround | Status |
|-------|------------|--------| |-------|------------|--------|
| pytest-asyncio version conflict | Use 0.24.0 | Documented | | `analysis_status` has no CheckConstraint | Valid values documented in code comments | Low priority |
| No local psql on Windows | Use `docker exec` | Documented | | Review queue/analytics pages have no frontend role gate | Backend 403 protects data; UX could show message | Low priority |
| Review queue capped at 50 with no pagination UI | Filters can narrow results | Low priority |

View File

@@ -20,6 +20,8 @@ from app.models.ai_suggestion import AISuggestion # noqa: F401
from app.models.kb_import import KBImport, KBImportNode # noqa: F401 from app.models.kb_import import KBImport, KBImportNode # noqa: F401
from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401 from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401
from app.models.psa_connection import PsaConnection # noqa: F401 from app.models.psa_connection import PsaConnection # noqa: F401
from app.models.ai_session import AISession # noqa: F401
from app.models.ai_session_step import AISessionStep # noqa: F401
from app.models.psa_post_log import PsaPostLog # noqa: F401 from app.models.psa_post_log import PsaPostLog # noqa: F401
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401 from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
from app.core.config import settings from app.core.config import settings

View File

@@ -0,0 +1,40 @@
"""add ai_session_id to script_generations
Revision ID: 3266dd9d8111
Revises: a0b871cb0c5e
Create Date: 2026-03-19 03:52:09.457961
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3266dd9d8111'
down_revision: Union[str, None] = 'a0b871cb0c5e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('script_generations', sa.Column(
'ai_session_id', sa.UUID(), nullable=True,
comment='FlowPilot AI session that triggered this generation',
))
op.create_index(
op.f('ix_script_generations_ai_session_id'),
'script_generations', ['ai_session_id'], unique=False,
)
op.create_foreign_key(
'fk_script_generations_ai_session_id',
'script_generations', 'ai_sessions',
['ai_session_id'], ['id'],
ondelete='SET NULL',
)
def downgrade() -> None:
op.drop_constraint('fk_script_generations_ai_session_id', 'script_generations', type_='foreignkey')
op.drop_index(op.f('ix_script_generations_ai_session_id'), table_name='script_generations')
op.drop_column('script_generations', 'ai_session_id')

View File

@@ -0,0 +1,64 @@
"""add flow_proposals table
Revision ID: 47c3b4f42e88
Revises: cc3201489b72
Create Date: 2026-03-19 03:11:33.663729
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '47c3b4f42e88'
down_revision: Union[str, None] = 'cc3201489b72'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('flow_proposals',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('account_id', sa.UUID(), nullable=False),
sa.Column('team_id', sa.UUID(), nullable=True),
sa.Column('source_session_id', sa.UUID(), nullable=False),
sa.Column('proposal_type', sa.String(length=30), nullable=False),
sa.Column('target_flow_id', sa.UUID(), nullable=True, comment='For enhancements: which existing flow to modify'),
sa.Column('title', sa.String(length=255), nullable=False, comment='Human-readable title for the proposed flow'),
sa.Column('description', sa.Text(), nullable=True, comment='AI-generated description of what this flow covers'),
sa.Column('proposed_flow_data', postgresql.JSONB(astext_type=sa.Text()), nullable=False, comment='Complete flow/tree_structure definition (nodes, edges, conditions)'),
sa.Column('proposed_diff', postgresql.JSONB(astext_type=sa.Text()), nullable=True, comment='For enhancements: what changed vs existing flow'),
sa.Column('confidence_score', sa.Float(), nullable=False, comment='How confident the system is in this proposal (0.0-1.0)'),
sa.Column('supporting_session_count', sa.Integer(), nullable=False, comment='Number of sessions with similar resolution paths'),
sa.Column('supporting_session_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=False, comment='Array of session IDs that support this proposal'),
sa.Column('problem_domain', sa.String(length=100), nullable=True),
sa.Column('status', sa.String(length=30), nullable=False),
sa.Column('reviewed_by', sa.UUID(), nullable=True),
sa.Column('reviewer_notes', sa.Text(), nullable=True),
sa.Column('published_flow_id', sa.UUID(), nullable=True, comment='The flow that was created/updated when this proposal was approved'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("proposal_type IN ('new_flow', 'enhancement', 'branch_addition', 'auto_reinforced')", name='ck_flow_proposals_type'),
sa.CheckConstraint("status IN ('pending', 'approved', 'modified', 'rejected', 'dismissed', 'auto_reinforced')", name='ck_flow_proposals_status'),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['published_flow_id'], ['trees.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['reviewed_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_session_id'], ['ai_sessions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['target_flow_id'], ['trees.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_flow_proposals_account_id'), 'flow_proposals', ['account_id'], unique=False)
op.create_index(op.f('ix_flow_proposals_source_session_id'), 'flow_proposals', ['source_session_id'], unique=False)
op.create_index(op.f('ix_flow_proposals_status'), 'flow_proposals', ['status'], unique=False)
op.create_index(op.f('ix_flow_proposals_team_id'), 'flow_proposals', ['team_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_flow_proposals_team_id'), table_name='flow_proposals')
op.drop_index(op.f('ix_flow_proposals_status'), table_name='flow_proposals')
op.drop_index(op.f('ix_flow_proposals_source_session_id'), table_name='flow_proposals')
op.drop_index(op.f('ix_flow_proposals_account_id'), table_name='flow_proposals')
op.drop_table('flow_proposals')

View File

@@ -0,0 +1,46 @@
"""add file_uploads table
Revision ID: 49150866ae44
Revises: e0d382f083d4
Create Date: 2026-03-20 03:15:10.227797
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '49150866ae44'
down_revision: Union[str, None] = 'e0d382f083d4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'file_uploads',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('account_id', sa.UUID(), nullable=False),
sa.Column('uploaded_by', sa.UUID(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('content_type', sa.String(length=100), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('storage_key', sa.String(length=500), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('storage_key'),
)
op.create_index(op.f('ix_file_uploads_account_id'), 'file_uploads', ['account_id'], unique=False)
op.create_index(op.f('ix_file_uploads_session_id'), 'file_uploads', ['session_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_file_uploads_session_id'), table_name='file_uploads')
op.drop_index(op.f('ix_file_uploads_account_id'), table_name='file_uploads')
op.drop_table('file_uploads')

View File

@@ -0,0 +1,38 @@
"""add branding and SSO columns to accounts
Revision ID: 58e3f27f3e8f
Revises: 9094262a4be3
Create Date: 2026-03-19 20:25:03.423778
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '58e3f27f3e8f'
down_revision: Union[str, None] = '9094262a4be3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Custom branding columns (Task 9)
op.add_column('accounts', sa.Column('branding_logo_url', sa.String(length=500), nullable=True))
op.add_column('accounts', sa.Column('branding_primary_color', sa.String(length=7), nullable=True))
op.add_column('accounts', sa.Column('branding_company_name', sa.String(length=200), nullable=True))
# SSO / SAML groundwork columns (Task 11)
op.add_column('accounts', sa.Column('sso_enabled', sa.Boolean(), server_default='false', nullable=False))
op.add_column('accounts', sa.Column('sso_provider', sa.String(length=20), nullable=True))
op.add_column('accounts', sa.Column('sso_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
def downgrade() -> None:
op.drop_column('accounts', 'sso_config')
op.drop_column('accounts', 'sso_provider')
op.drop_column('accounts', 'sso_enabled')
op.drop_column('accounts', 'branding_company_name')
op.drop_column('accounts', 'branding_primary_color')
op.drop_column('accounts', 'branding_logo_url')

View File

@@ -0,0 +1,40 @@
"""add gallery featuring columns to trees and script_templates
Revision ID: 9094262a4be3
Revises: b09c3789b7e6
Create Date: 2026-03-19 19:07:25.964399
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9094262a4be3'
down_revision: Union[str, None] = 'b09c3789b7e6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add gallery featuring columns to trees
op.add_column('trees', sa.Column('is_gallery_featured', sa.Boolean(), nullable=False, server_default=sa.text('false')))
op.add_column('trees', sa.Column('gallery_sort_order', sa.Integer(), nullable=False, server_default=sa.text('0')))
op.create_index(op.f('ix_trees_is_gallery_featured'), 'trees', ['is_gallery_featured'], unique=False)
# Add gallery featuring columns to script_templates
op.add_column('script_templates', sa.Column('is_gallery_featured', sa.Boolean(), nullable=False, server_default=sa.text('false')))
op.add_column('script_templates', sa.Column('gallery_sort_order', sa.Integer(), nullable=False, server_default=sa.text('0')))
op.create_index(op.f('ix_script_templates_is_gallery_featured'), 'script_templates', ['is_gallery_featured'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_script_templates_is_gallery_featured'), table_name='script_templates')
op.drop_column('script_templates', 'gallery_sort_order')
op.drop_column('script_templates', 'is_gallery_featured')
op.drop_index(op.f('ix_trees_is_gallery_featured'), table_name='trees')
op.drop_column('trees', 'gallery_sort_order')
op.drop_column('trees', 'is_gallery_featured')

View File

@@ -0,0 +1,28 @@
"""add analysis_status to ai_sessions
Revision ID: a0b871cb0c5e
Revises: 47c3b4f42e88
Create Date: 2026-03-19 03:26:26.965134
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a0b871cb0c5e'
down_revision: Union[str, None] = '47c3b4f42e88'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('ai_sessions', sa.Column(
'analysis_status', sa.String(length=20), nullable=True,
comment='Knowledge Flywheel status: null (N/A), pending, completed, failed',
))
def downgrade() -> None:
op.drop_column('ai_sessions', 'analysis_status')

View File

@@ -0,0 +1,83 @@
"""add ai_session_embeddings table for similar-session matching
Revision ID: a7c9e3b1f402
Revises: dbf67047d4c8
Create Date: 2026-03-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "a7c9e3b1f402"
down_revision: Union[str, None] = "dbf67047d4c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# pgvector extension should already exist from migration 042
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"ai_session_embeddings",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"session_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"account_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column(
"embedding_model",
sa.String(50),
nullable=False,
server_default="voyage-3.5",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
),
)
# Add vector column via raw SQL (pgvector type not in SA dialect)
op.execute(
"ALTER TABLE ai_session_embeddings ADD COLUMN embedding vector(1024)"
)
op.create_index(
"ix_ai_session_embeddings_session_id",
"ai_session_embeddings",
["session_id"],
unique=True,
)
op.create_index(
"ix_ai_session_embeddings_account_id",
"ai_session_embeddings",
["account_id"],
)
def downgrade() -> None:
op.drop_table("ai_session_embeddings")

View File

@@ -0,0 +1,83 @@
"""add notification tables
Revision ID: b09c3789b7e6
Revises: 3266dd9d8111
Create Date: 2026-03-19 06:16:46.817718
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'b09c3789b7e6'
down_revision: Union[str, None] = '3266dd9d8111'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('notification_configs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('account_id', sa.UUID(), nullable=False),
sa.Column('channel', sa.String(length=20), nullable=False),
sa.Column('webhook_url', sa.String(length=500), nullable=True),
sa.Column('email_addresses', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('events_enabled', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("channel IN ('email', 'slack_webhook', 'teams_webhook')", name='ck_notification_configs_channel'),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notification_configs_account_id'), 'notification_configs', ['account_id'], unique=False)
op.create_table('notifications',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('account_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('event', sa.String(length=50), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('body', sa.String(length=500), nullable=True),
sa.Column('link', sa.String(length=500), nullable=True),
sa.Column('is_read', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_account_id'), 'notifications', ['account_id'], unique=False)
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
op.create_table('notification_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('notification_config_id', sa.UUID(), nullable=False),
sa.Column('event', sa.String(length=50), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('retry_count', sa.Integer(), nullable=False),
sa.Column('max_retries', sa.Integer(), nullable=False),
sa.Column('last_error', sa.String(length=1000), nullable=True),
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('sent', 'failed', 'retrying', 'exhausted')", name='ck_notification_logs_status'),
sa.ForeignKeyConstraint(['notification_config_id'], ['notification_configs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notification_logs_notification_config_id'), 'notification_logs', ['notification_config_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_notification_logs_notification_config_id'), table_name='notification_logs')
op.drop_table('notification_logs')
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
op.drop_index(op.f('ix_notifications_account_id'), table_name='notifications')
op.drop_table('notifications')
op.drop_index(op.f('ix_notification_configs_account_id'), table_name='notification_configs')
op.drop_table('notification_configs')

View File

@@ -0,0 +1,34 @@
"""drop file_uploads session_id foreign key constraint
Revision ID: b8d2f4a6c091
Revises: a7c9e3b1f402
Create Date: 2026-03-20 00:00:00.000000
The session_id column on file_uploads previously referenced ai_sessions.id.
Removing the FK allows the column to reference either AI sessions or regular
sessions without a constraint violation, while keeping the index for query
performance.
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b8d2f4a6c091'
down_revision: Union[str, None] = 'a7c9e3b1f402'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint('file_uploads_session_id_fkey', 'file_uploads', type_='foreignkey')
def downgrade() -> None:
op.create_foreign_key(
'file_uploads_session_id_fkey',
'file_uploads', 'ai_sessions',
['session_id'], ['id'],
ondelete='SET NULL',
)

View File

@@ -0,0 +1,78 @@
"""phase2 psa flowpilot integration
Revision ID: bb2101378a61
Revises: f1a2b3c4d5e6
Create Date: 2026-03-18 23:05:01.099910
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bb2101378a61'
down_revision: Union[str, None] = 'f1a2b3c4d5e6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add flowpilot_settings JSONB to psa_connections
op.add_column('psa_connections', sa.Column(
'flowpilot_settings',
postgresql.JSONB(astext_type=sa.Text()),
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
op.add_column('psa_post_log', sa.Column(
'ai_session_id',
sa.Uuid(),
nullable=True,
comment='FK to AI sessions (Phase 2). Original session_id FK remains for legacy sessions.',
))
op.create_index(
op.f('ix_psa_post_log_ai_session_id'),
'psa_post_log',
['ai_session_id'],
)
op.create_foreign_key(
'fk_psa_post_log_ai_session_id',
'psa_post_log',
'ai_sessions',
['ai_session_id'],
['id'],
ondelete='CASCADE',
)
# 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 and next_retry_at 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',
))
def downgrade() -> None:
op.drop_column('psa_post_log', 'next_retry_at')
op.drop_column('psa_post_log', 'retry_count')
op.alter_column('psa_post_log', 'session_id', nullable=False)
op.drop_constraint('fk_psa_post_log_ai_session_id', 'psa_post_log', type_='foreignkey')
op.drop_index(op.f('ix_psa_post_log_ai_session_id'), table_name='psa_post_log')
op.drop_column('psa_post_log', 'ai_session_id')
op.drop_column('psa_connections', 'flowpilot_settings')

View File

@@ -0,0 +1,35 @@
"""add requesting_escalation status
Revision ID: cc3201489b72
Revises: bb2101378a61
Create Date: 2026-03-18 23:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'cc3201489b72'
down_revision: Union[str, None] = 'bb2101378a61'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop old status constraint and recreate with new values
op.drop_constraint('ck_ai_sessions_status', 'ai_sessions', type_='check')
op.create_check_constraint(
'ck_ai_sessions_status',
'ai_sessions',
"status IN ('active', 'paused', 'resolved', 'escalated', 'requesting_escalation', 'abandoned')",
)
def downgrade() -> None:
op.drop_constraint('ck_ai_sessions_status', 'ai_sessions', type_='check')
op.create_check_constraint(
'ck_ai_sessions_status',
'ai_sessions',
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
)

View File

@@ -0,0 +1,43 @@
"""add full-text search vector to ai_sessions
Revision ID: dbf67047d4c8
Revises: 49150866ae44
Create Date: 2026-03-20 03:36:29.910843
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'dbf67047d4c8'
down_revision: Union[str, None] = '49150866ae44'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add generated tsvector column for full-text search
# Indexes: problem_summary, resolution_summary, escalation_reason, problem_domain
op.execute("""
ALTER TABLE ai_sessions ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
coalesce(problem_summary, '') || ' ' ||
coalesce(resolution_summary, '') || ' ' ||
coalesce(escalation_reason, '') || ' ' ||
coalesce(problem_domain, ''))
) STORED
""")
# Add GIN index for fast FTS lookups
op.execute("""
CREATE INDEX IF NOT EXISTS idx_ai_sessions_search
ON ai_sessions USING gin(search_vector)
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_ai_sessions_search")
op.execute("ALTER TABLE ai_sessions DROP COLUMN IF EXISTS search_vector")

View File

@@ -0,0 +1,46 @@
"""add flow tracking columns and psa_activity_logs table
Revision ID: e0d382f083d4
Revises: 58e3f27f3e8f
Create Date: 2026-03-19 23:59:42.346587
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e0d382f083d4'
down_revision: Union[str, None] = '58e3f27f3e8f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create psa_activity_logs table
op.create_table(
'psa_activity_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('account_id', sa.UUID(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=True),
sa.Column('activity_type', sa.String(length=50), nullable=False),
sa.Column('hours_logged', sa.Float(), nullable=True),
sa.Column('psa_ticket_id', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_psa_activity_logs_account_id'), 'psa_activity_logs', ['account_id'], unique=False)
# Flow quality tracking columns on trees (usage_count, success_rate, last_matched_at)
# Note: usage_count, success_rate, and last_matched_at may already exist on this instance.
# These are included here for environments where they were not yet added.
# The columns are guarded to be safe — skip if already present.
def downgrade() -> None:
op.drop_index(op.f('ix_psa_activity_logs_account_id'), table_name='psa_activity_logs')
op.drop_table('psa_activity_logs')

View File

@@ -0,0 +1,36 @@
"""widen ai_sessions status to varchar 30
Revision ID: f0aad74ea51b
Revises: b8d2f4a6c091
Create Date: 2026-03-21 01:21:03.742028
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f0aad74ea51b'
down_revision: Union[str, None] = 'b8d2f4a6c091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column(
"ai_sessions", "status",
type_=sa.String(30),
existing_type=sa.String(20),
existing_nullable=False,
)
def downgrade() -> None:
op.alter_column(
"ai_sessions", "status",
type_=sa.String(20),
existing_type=sa.String(30),
existing_nullable=False,
)

View File

@@ -0,0 +1,129 @@
"""add ai_sessions and ai_session_steps tables
Revision ID: f1a2b3c4d5e6
Revises: ee98013dd18c
Create Date: 2026-03-18
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers, used by Alembic.
revision = "f1a2b3c4d5e6"
down_revision = "ee98013dd18c"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── ai_sessions table ──
op.create_table(
"ai_sessions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True),
# Intake
sa.Column("intake_type", sa.String(20), nullable=False, server_default="free_text"),
sa.Column("intake_content", JSONB, nullable=False, server_default="{}"),
sa.Column("problem_summary", sa.Text, nullable=True),
sa.Column("problem_domain", sa.String(100), nullable=True),
# Session state
sa.Column("status", sa.String(20), nullable=False, server_default="active", index=True),
sa.Column("confidence_tier", sa.String(20), nullable=False, server_default="discovery"),
sa.Column("confidence_score", sa.Float, nullable=False, server_default="0.0"),
# Flow matching
sa.Column("matched_flow_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="SET NULL"), nullable=True),
sa.Column("match_score", sa.Float, nullable=True),
# PSA link
sa.Column("psa_ticket_id", sa.String(100), nullable=True),
sa.Column("psa_connection_id", UUID(as_uuid=True), sa.ForeignKey("psa_connections.id", ondelete="SET NULL"), nullable=True),
sa.Column("ticket_data", JSONB, nullable=True),
# Resolution / Escalation
sa.Column("resolution_summary", sa.Text, nullable=True),
sa.Column("resolution_action", sa.Text, nullable=True),
sa.Column("escalation_reason", sa.Text, nullable=True),
sa.Column("escalation_package", JSONB, nullable=True),
sa.Column("escalated_to_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
# Feedback
sa.Column("session_rating", sa.Integer, nullable=True),
sa.Column("session_feedback", sa.Text, nullable=True),
# AI tracking
sa.Column("total_input_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("total_output_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("step_count", sa.Integer, nullable=False, server_default="0"),
# Timestamps
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
# LLM context
sa.Column("system_prompt_snapshot", sa.Text, nullable=True),
sa.Column("conversation_messages", JSONB, nullable=False, server_default="[]"),
# Check constraints
sa.CheckConstraint(
"intake_type IN ('free_text', 'psa_ticket', 'screenshot', 'log_paste', 'combined')",
name="ck_ai_sessions_intake_type",
),
sa.CheckConstraint(
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
name="ck_ai_sessions_status",
),
sa.CheckConstraint(
"confidence_tier IN ('guided', 'exploring', 'discovery')",
name="ck_ai_sessions_confidence_tier",
),
)
# ── ai_session_steps table ──
op.create_table(
"ai_session_steps",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("step_order", sa.Integer, nullable=False),
sa.Column("step_type", sa.String(30), nullable=False),
# Content
sa.Column("content", JSONB, nullable=False, server_default="{}"),
sa.Column("context_message", sa.Text, nullable=True),
# Options
sa.Column("options_presented", JSONB, nullable=True),
# Engineer response
sa.Column("selected_option", sa.String(500), nullable=True),
sa.Column("free_text_input", sa.Text, nullable=True),
sa.Column("was_free_text", sa.Boolean, nullable=False, server_default="false"),
sa.Column("was_skipped", sa.Boolean, nullable=False, server_default="false"),
# Action results
sa.Column("action_result", JSONB, nullable=True),
# Script generation link
sa.Column("script_generation_id", UUID(as_uuid=True), sa.ForeignKey("script_generations.id", ondelete="SET NULL"), nullable=True),
# AI internals
sa.Column("confidence_at_step", sa.Float, nullable=False, server_default="0.0"),
sa.Column("ai_reasoning", sa.Text, nullable=True),
sa.Column("input_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("output_tokens", sa.Integer, nullable=False, server_default="0"),
# Timestamps
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("responded_at", sa.DateTime(timezone=True), nullable=True),
# Check constraint
sa.CheckConstraint(
"step_type IN ('question', 'action', 'script_generation', 'verification', "
"'info_request', 'note', 'intake_analysis')",
name="ck_ai_session_steps_step_type",
),
)
# ── Add flow matching columns to trees table ──
op.add_column("trees", sa.Column("origin", sa.String(20), nullable=True, comment="manual | ai_generated | ai_enhanced"))
op.add_column("trees", sa.Column("source_session_id", UUID(as_uuid=True), nullable=True))
op.add_column("trees", sa.Column("match_keywords", JSONB, nullable=True, server_default="[]"))
op.add_column("trees", sa.Column("success_rate", sa.Float, nullable=True))
op.add_column("trees", sa.Column("last_matched_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("trees", "last_matched_at")
op.drop_column("trees", "success_rate")
op.drop_column("trees", "match_keywords")
op.drop_column("trees", "source_session_id")
op.drop_column("trees", "origin")
op.drop_table("ai_session_steps")
op.drop_table("ai_sessions")

View File

@@ -145,6 +145,22 @@ async def require_engineer_or_admin(
) )
async def require_team_admin(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""Require team admin, account owner, or super admin role."""
if current_user.is_super_admin:
return current_user
if current_user.is_team_admin:
return current_user
if current_user.account_role == "owner":
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Team admin access required"
)
async def require_account_owner( async def require_account_owner(
current_user: Annotated[User, Depends(get_current_active_user)] current_user: Annotated[User, Depends(get_current_active_user)]
) -> User: ) -> User:

View File

@@ -465,3 +465,96 @@ async def delete_account(
await db.commit() await db.commit()
return {"message": "Account deleted"} return {"message": "Account deleted"}
# ─── Account Branding Endpoints (Task 9) ──────────────────────────────────────
class AccountBrandingResponse(BaseModel):
logo_url: Optional[str] = None
primary_color: Optional[str] = None
company_name: Optional[str] = None
model_config = {"from_attributes": True}
class AccountBrandingUpdate(BaseModel):
logo_url: Optional[str] = None
primary_color: Optional[str] = None
company_name: Optional[str] = None
@router.get("/me/branding", response_model=AccountBrandingResponse)
async def get_account_branding(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Get custom branding settings for the current account."""
result = await db.execute(select(Account).where(Account.id == current_user.account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return AccountBrandingResponse(
logo_url=account.branding_logo_url,
primary_color=account.branding_primary_color,
company_name=account.branding_company_name,
)
@router.patch("/me/branding", response_model=AccountBrandingResponse)
async def update_account_branding(
data: AccountBrandingUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner)],
):
"""Update custom branding settings. Account owner only."""
result = await db.execute(select(Account).where(Account.id == current_user.account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
if data.logo_url is not None:
account.branding_logo_url = data.logo_url or None
if data.primary_color is not None:
# Validate hex color format (#RRGGBB)
color = data.primary_color.strip()
if color and (len(color) != 7 or not color.startswith("#")):
raise HTTPException(status_code=400, detail="primary_color must be a 7-character hex string like #06b6d4")
account.branding_primary_color = color or None
if data.company_name is not None:
account.branding_company_name = data.company_name.strip() or None
await db.commit()
await db.refresh(account)
return AccountBrandingResponse(
logo_url=account.branding_logo_url,
primary_color=account.branding_primary_color,
company_name=account.branding_company_name,
)
# ─── SSO Status Endpoint (Task 11) ────────────────────────────────────────────
class AccountSSOStatusResponse(BaseModel):
sso_enabled: bool
sso_provider: Optional[str] = None
model_config = {"from_attributes": True}
@router.get("/me/sso", response_model=AccountSSOStatusResponse)
async def get_sso_status(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Get SSO configuration status for the current account."""
result = await db.execute(select(Account).where(Account.id == current_user.account_id))
account = result.scalar_one_or_none()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
return AccountSSOStatusResponse(
sso_enabled=account.sso_enabled,
sso_provider=account.sso_provider,
)

View File

@@ -0,0 +1,191 @@
"""Admin gallery curation endpoints.
Allows super admins to toggle is_gallery_featured and update gallery_sort_order
on Tree (flows) and ScriptTemplate (scripts) records.
"""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.core.database import get_db
from app.models.script_template import ScriptTemplate
from app.models.tree import Tree
from app.models.user import User
router = APIRouter(prefix="/admin/gallery", tags=["admin-gallery"])
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class FeatureToggle(BaseModel):
is_gallery_featured: bool
class SortOrderUpdate(BaseModel):
gallery_sort_order: int
# ---------------------------------------------------------------------------
# Response helpers
# ---------------------------------------------------------------------------
def _flow_summary(tree: Tree) -> dict:
return {
"id": str(tree.id),
"name": tree.name,
"tree_type": tree.tree_type,
"is_gallery_featured": tree.is_gallery_featured,
"gallery_sort_order": tree.gallery_sort_order,
"visibility": tree.visibility,
}
def _script_summary(script: ScriptTemplate) -> dict:
return {
"id": str(script.id),
"name": script.name,
"is_gallery_featured": script.is_gallery_featured,
"gallery_sort_order": script.gallery_sort_order,
"is_active": script.is_active,
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/featured")
async def list_featured(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all featured flows and scripts (super admin only)."""
flows_result = await db.execute(
select(Tree)
.where(Tree.is_gallery_featured == True) # noqa: E712
.order_by(Tree.gallery_sort_order.asc(), Tree.name.asc())
)
flows = flows_result.scalars().all()
scripts_result = await db.execute(
select(ScriptTemplate)
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
.order_by(ScriptTemplate.gallery_sort_order.asc(), ScriptTemplate.name.asc())
)
scripts = scripts_result.scalars().all()
return {
"flows": [_flow_summary(f) for f in flows],
"scripts": [_script_summary(s) for s in scripts],
}
@router.get("/items")
async def list_all_items(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List ALL flows and scripts with their gallery status (super admin only)."""
flows_result = await db.execute(
select(Tree)
.where(Tree.visibility == "public")
.order_by(Tree.gallery_sort_order.asc(), Tree.name.asc())
)
flows = flows_result.scalars().all()
scripts_result = await db.execute(
select(ScriptTemplate)
.order_by(ScriptTemplate.gallery_sort_order.asc(), ScriptTemplate.name.asc())
)
scripts = scripts_result.scalars().all()
return {
"flows": [_flow_summary(f) for f in flows],
"scripts": [_script_summary(s) for s in scripts],
}
@router.patch("/flows/{flow_id}/feature")
async def toggle_flow_featured(
flow_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a flow (super admin only)."""
result = await db.execute(select(Tree).where(Tree.id == flow_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Flow not found")
tree.is_gallery_featured = body.is_gallery_featured
await db.commit()
await db.refresh(tree)
return _flow_summary(tree)
@router.patch("/flows/{flow_id}/sort-order")
async def update_flow_sort_order(
flow_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a flow (super admin only)."""
result = await db.execute(select(Tree).where(Tree.id == flow_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Flow not found")
tree.gallery_sort_order = body.gallery_sort_order
await db.commit()
await db.refresh(tree)
return _flow_summary(tree)
@router.patch("/scripts/{script_id}/feature")
async def toggle_script_featured(
script_id: UUID,
body: FeatureToggle,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Toggle is_gallery_featured on a script (super admin only)."""
result = await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == script_id))
script = result.scalar_one_or_none()
if not script:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
script.is_gallery_featured = body.is_gallery_featured
await db.commit()
await db.refresh(script)
return _script_summary(script)
@router.patch("/scripts/{script_id}/sort-order")
async def update_script_sort_order(
script_id: UUID,
body: SortOrderUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Update gallery_sort_order on a script (super admin only)."""
result = await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == script_id))
script = result.scalar_one_or_none()
if not script:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
script.gallery_sort_order = body.gallery_sort_order
await db.commit()
await db.refresh(script)
return _script_summary(script)

View File

@@ -0,0 +1,779 @@
"""FlowPilot AI session endpoints.
CRUD and interaction endpoints for AI-powered troubleshooting sessions:
POST /ai-sessions — Start a new session
POST /ai-sessions/{id}/respond — Submit step response, get next step
POST /ai-sessions/{id}/resolve — Resolve the session
POST /ai-sessions/{id}/escalate — Escalate the session
GET /ai-sessions — List user's sessions (paginated)
GET /ai-sessions/{id} — Get session detail with all steps
GET /ai-sessions/{id}/documentation — Get auto-generated documentation
POST /ai-sessions/{id}/rate — Submit post-session rating
"""
import logging
from datetime import datetime
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import or_, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.config import settings
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
from app.models.user import User
from app.models.ai_session import AISession
from app.schemas.ai_session import (
AISessionCreateRequest,
AISessionCreateResponse,
StepResponseRequest,
StepResponseResponse,
ResolveSessionRequest,
EscalateSessionRequest,
SessionCloseResponse,
SessionDocumentation,
RateSessionRequest,
PickupSessionRequest,
LinkTicketRequest,
AISessionSummary,
AISessionDetail,
AISessionStepResponse,
AISessionSearchResult,
StepOptionSchema,
)
from app.services import flowpilot_engine
from app.services.psa_documentation_service import retry_failed_push
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai-sessions", tags=["ai-sessions"])
def _build_session_detail(session: AISession) -> AISessionDetail:
"""Build AISessionDetail from ORM session with properly mapped steps.
AISessionDetail.model_validate(session) fails because the ORM steps
relationship uses 'id' while AISessionStepResponse expects 'step_id'.
This helper manually maps all fields to avoid that validation error.
"""
step_responses = []
for step in (session.steps or []):
options = []
if step.options_presented:
options = [
StepOptionSchema(
label=opt.get("label", ""),
value=opt.get("value", ""),
followup_hint=opt.get("followup_hint"),
)
for opt in step.options_presented
]
content = step.content or {}
step_responses.append(AISessionStepResponse(
step_id=step.id,
step_order=step.step_order,
step_type=step.step_type,
content=content,
context_message=step.context_message,
options=options,
allow_free_text=content.get("allow_free_text", True),
allow_skip=content.get("allow_skip", True),
confidence_tier=session.confidence_tier,
confidence_score=step.confidence_at_step,
))
return AISessionDetail(
id=session.id,
status=session.status,
intake_type=session.intake_type,
intake_content=session.intake_content or {},
problem_summary=session.problem_summary,
problem_domain=session.problem_domain,
confidence_tier=session.confidence_tier,
step_count=session.step_count,
session_rating=session.session_rating,
psa_ticket_id=session.psa_ticket_id,
psa_connection_id=session.psa_connection_id,
escalation_reason=session.escalation_reason,
matched_flow_id=session.matched_flow_id,
match_score=getattr(session, 'match_score', None),
resolution_summary=session.resolution_summary,
resolution_action=getattr(session, 'resolution_action', None),
session_feedback=session.session_feedback,
ticket_data=session.ticket_data,
created_at=session.created_at,
resolved_at=session.resolved_at,
steps=step_responses,
)
def _require_ai_enabled() -> None:
if not settings.ai_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
)
async def _check_quota(user: User, db: AsyncSession) -> None:
"""Check AI quota and raise 429 if exceeded."""
allowed, quota_status = await check_ai_quota(
user_id=user.id,
account_id=user.account_id,
db=db,
billing_anchor=user.ai_billing_cycle_anchor_at,
is_super_admin=user.is_super_admin,
)
if not allowed:
reset_key = "daily_reset_at" if quota_status.get("deny_reason") == "daily" else "monthly_reset_at"
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"message": f"AI limit exceeded ({quota_status['deny_reason']})",
"reset_at": quota_status.get(reset_key),
"quota": quota_status,
},
)
async def _record_usage(
user: User,
db: AsyncSession,
generation_type: str,
input_tokens: int,
output_tokens: int,
succeeded: bool,
session_id: Optional[UUID] = None,
error_code: Optional[str] = None,
) -> None:
"""Record AI usage after an LLM call."""
plan = await get_user_plan(user.account_id, db)
estimated_cost = (
input_tokens * 3.0 / 1_000_000
+ output_tokens * 15.0 / 1_000_000
)
await record_ai_usage(
user_id=user.id,
account_id=user.account_id,
conversation_id=None,
generation_type=generation_type,
tier=plan,
input_tokens=input_tokens,
output_tokens=output_tokens,
estimated_cost=estimated_cost,
succeeded=succeeded,
counts_toward_quota=True,
error_code=error_code,
extra_data={"ai_session_id": str(session_id)} if session_id else None,
db=db,
)
# ── Create session ──
@router.post("", response_model=AISessionCreateResponse, status_code=201)
@limiter.limit("5/minute")
async def create_session(
request: Request,
data: AISessionCreateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Start a new FlowPilot troubleshooting session."""
_require_ai_enabled()
await _check_quota(current_user, db)
try:
result = await flowpilot_engine.start_session(
request=data,
user_id=current_user.id,
account_id=current_user.account_id,
team_id=current_user.team_id,
db=db,
)
except Exception as e:
logger.exception("FlowPilot session start failed: %s", e)
# Rollback the failed transaction before attempting usage recording
await db.rollback()
try:
await _record_usage(
current_user, db,
generation_type="flowpilot_start",
input_tokens=0, output_tokens=0,
succeeded=False, error_code=type(e).__name__,
)
await db.commit()
except Exception:
logger.warning("Failed to record usage after session start failure", exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await _record_usage(
current_user, db,
generation_type="flowpilot_start",
input_tokens=result.first_step.confidence_score and 0, # Tracked on session
output_tokens=0,
succeeded=True,
session_id=result.session_id,
)
await db.commit()
return result
# ── Respond to step ──
@router.post("/{session_id}/respond", response_model=StepResponseResponse)
@limiter.limit("15/minute")
async def respond_to_step(
request: Request,
session_id: UUID,
data: StepResponseRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Submit an engineer's response to a FlowPilot step and get the next step."""
_require_ai_enabled()
await _check_quota(current_user, db)
try:
result = await flowpilot_engine.process_response(
session_id=session_id,
request=data,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except Exception as e:
logger.exception("FlowPilot response failed: %s", e)
await db.rollback()
try:
await _record_usage(
current_user, db,
generation_type="flowpilot_respond",
input_tokens=0, output_tokens=0,
succeeded=False,
session_id=session_id,
error_code=type(e).__name__,
)
await db.commit()
except Exception:
logger.warning("Failed to record usage after response failure", exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await _record_usage(
current_user, db,
generation_type="flowpilot_respond",
input_tokens=0, output_tokens=0,
succeeded=True,
session_id=session_id,
)
await db.commit()
return result
# ── Resolve ──
@router.post("/{session_id}/resolve", response_model=SessionCloseResponse)
@limiter.limit("15/minute")
async def resolve_session(
request: Request,
session_id: UUID,
data: ResolveSessionRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Resolve a FlowPilot session and generate documentation."""
try:
result = await flowpilot_engine.resolve_session(
session_id=session_id,
request=data,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
return result
# ── Escalate ──
@router.post("/{session_id}/escalate", response_model=SessionCloseResponse)
@limiter.limit("15/minute")
async def escalate_session(
request: Request,
session_id: UUID,
data: EscalateSessionRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Escalate a FlowPilot session to another engineer."""
try:
result = await flowpilot_engine.escalate_session(
session_id=session_id,
request=data,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
return result
# ── Pause ──
@router.post("/{session_id}/pause", status_code=204)
@limiter.limit("15/minute")
async def pause_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Pause an active FlowPilot session for later resume."""
try:
await flowpilot_engine.pause_session(
session_id=session_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Resume ──
@router.post("/{session_id}/resume", status_code=204)
@limiter.limit("15/minute")
async def resume_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Resume a paused FlowPilot session."""
try:
await flowpilot_engine.resume_session(
session_id=session_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Escalation Queue ──
@router.get("/escalation-queue", response_model=list[AISessionSummary])
@limiter.limit("30/minute")
async def get_escalation_queue(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""List sessions requesting escalation for the current user's team/account."""
# Match by team_id if available, otherwise fall back to account_id
if current_user.team_id:
scope_filter = AISession.team_id == current_user.team_id
elif current_user.account_id:
scope_filter = AISession.account_id == current_user.account_id
else:
return []
result = await db.execute(
select(AISession)
.where(
scope_filter,
AISession.status == "requesting_escalation",
)
.order_by(AISession.created_at.desc())
)
sessions = result.scalars().all()
return [AISessionSummary.model_validate(s) for s in sessions]
# ── Pickup Escalated Session ──
@router.post("/{session_id}/pickup", response_model=StepResponseResponse)
@limiter.limit("5/minute")
async def pickup_session(
request: Request,
session_id: UUID,
data: PickupSessionRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Pick up an escalated session as a new engineer."""
_require_ai_enabled()
await _check_quota(current_user, db)
try:
result = await flowpilot_engine.pickup_session(
session_id=session_id,
resume_mode=data.resume_mode,
additional_context=data.additional_context,
user_id=current_user.id,
team_id=current_user.team_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except Exception as e:
logger.exception("FlowPilot pickup failed: %s", e)
await db.rollback()
try:
await _record_usage(
current_user, db,
generation_type="flowpilot_pickup",
input_tokens=0, output_tokens=0,
succeeded=False,
session_id=session_id,
error_code=type(e).__name__,
)
await db.commit()
except Exception:
logger.warning("Failed to record usage after pickup failure", exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await _record_usage(
current_user, db,
generation_type="flowpilot_pickup",
input_tokens=0, output_tokens=0,
succeeded=True,
session_id=session_id,
)
await db.commit()
return result
# ── Link Ticket ──
@router.post("/{session_id}/link-ticket", response_model=AISessionDetail)
@limiter.limit("10/minute")
async def link_ticket_to_session(
request: Request,
session_id: UUID,
data: LinkTicketRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Link a PSA ticket to an in-progress session retroactively."""
try:
await flowpilot_engine.link_ticket(
session_id=session_id,
psa_ticket_id=data.psa_ticket_id,
psa_connection_id=data.psa_connection_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# Return updated session detail
result = await db.execute(
select(AISession)
.options(selectinload(AISession.steps))
.where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return _build_session_detail(session)
# ── Search sessions (Command Palette) ──
@router.get("/search", response_model=list[AISessionSearchResult])
@limiter.limit("30/minute")
async def search_sessions(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
q: str = Query(..., min_length=2, max_length=200),
limit: int = Query(5, ge=1, le=20),
):
"""Search AI sessions by content using full-text search. Used by Command Palette."""
result = await db.execute(
select(AISession)
.where(
or_(
AISession.user_id == current_user.id,
AISession.account_id == current_user.account_id,
),
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)"),
)
.params(q=q)
.order_by(AISession.created_at.desc())
.limit(limit)
)
sessions = result.scalars().all()
return [
AISessionSearchResult(
id=s.id,
problem_summary=s.problem_summary,
problem_domain=s.problem_domain,
status=s.status,
created_at=s.created_at,
)
for s in sessions
]
# ── Similar Sessions ──
@router.get("/{session_id}/similar")
@limiter.limit("15/minute")
async def get_similar_sessions(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
limit: int = Query(5, ge=1, le=20),
):
"""Find sessions semantically similar to this one using vector embeddings."""
from app.services.session_embedding_service import find_similar_sessions
if not current_user.account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account")
results = await find_similar_sessions(
session_id=session_id,
account_id=current_user.account_id,
db=db,
limit=limit,
)
return results
# ── List sessions ──
@router.get("", response_model=list[AISessionSummary])
@limiter.limit("30/minute")
async def list_sessions(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
session_status: Optional[str] = Query(None, alias="status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
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),
):
"""List the current user's AI sessions (owned or picked up)."""
user_id_str = str(current_user.id)
query = (
select(AISession)
.where(
or_(
AISession.user_id == current_user.id,
AISession.escalation_package["picked_up_by"].as_string() == user_id_str,
)
)
.order_by(AISession.created_at.desc())
.offset(skip)
.limit(limit)
)
if session_status:
query = query.where(AISession.status == session_status)
if problem_domain:
query = query.where(AISession.problem_domain == problem_domain)
if matched_flow_id:
query = query.where(AISession.matched_flow_id == matched_flow_id)
if confidence_tier:
query = query.where(AISession.confidence_tier == confidence_tier)
if ticket_id:
query = query.where(AISession.psa_ticket_id == ticket_id)
if date_from:
query = query.where(AISession.created_at >= date_from)
if date_to:
query = query.where(AISession.created_at <= date_to)
if q:
query = query.where(
text("ai_sessions.search_vector @@ plainto_tsquery('english', :q)")
).params(q=q)
result = await db.execute(query)
sessions = result.scalars().all()
return [AISessionSummary.model_validate(s) for s in sessions]
# ── Get session detail ──
@router.get("/{session_id}", response_model=AISessionDetail)
@limiter.limit("30/minute")
async def get_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get full session detail with all steps."""
result = await db.execute(
select(AISession)
.options(selectinload(AISession.steps))
.where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
# Allow access if user is owner, escalation target, or picked-up handler
pkg = session.escalation_package or {}
is_handler = pkg.get("picked_up_by") == str(current_user.id)
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
return _build_session_detail(session)
# ── Documentation ──
@router.get("/{session_id}/documentation", response_model=SessionDocumentation)
@limiter.limit("30/minute")
async def get_documentation(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get auto-generated documentation for a session."""
try:
return await flowpilot_engine.get_session_documentation(
session_id=session_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
# ── Rate ──
@router.post("/{session_id}/rate", status_code=204)
@limiter.limit("15/minute")
async def rate_session(
request: Request,
session_id: UUID,
data: RateSessionRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Submit a post-session rating."""
try:
await flowpilot_engine.rate_session(
session_id=session_id,
rating=data.rating,
feedback=data.feedback,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Retry PSA Push ──
@router.post("/{session_id}/retry-psa-push")
@limiter.limit("5/minute")
async def retry_psa_push_endpoint(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Manually retry a failed PSA documentation push."""
from app.models.psa_post_log import PsaPostLog
# Find the latest failed push log for this session
result = await db.execute(
select(PsaPostLog)
.where(
PsaPostLog.ai_session_id == session_id,
PsaPostLog.status.in_(["failed", "pending_retry"]),
)
.order_by(PsaPostLog.posted_at.desc())
.limit(1)
)
log_entry = result.scalar_one_or_none()
if not log_entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No failed PSA push found for this session",
)
# Reset to pending_retry and attempt immediately
log_entry.status = "pending_retry"
log_entry.retry_count = max(0, log_entry.retry_count - 1) # Give one more attempt
success = await retry_failed_push(log_entry, db)
await db.commit()
return {
"psa_push_status": "sent" if success else log_entry.status,
"psa_push_error": log_entry.error_message if not success else None,
}

View File

@@ -0,0 +1,314 @@
"""Review Queue API — CRUD for flow proposals.
Endpoints for listing, reviewing, and managing Knowledge Flywheel proposals:
GET /flow-proposals — List proposals (filterable)
GET /flow-proposals/stats — Dashboard stats
GET /flow-proposals/{id} — Get proposal detail
POST /flow-proposals/{id}/review — Approve, reject, modify, or dismiss
"""
import logging
import uuid
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select, func, case
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.rate_limit import limiter
from app.services.notification_service import notify
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin, require_team_admin
from app.models.user import User
from app.models.tree import Tree
from app.models.flow_proposal import FlowProposal
from app.schemas.flow_proposal import (
FlowProposalSummary,
FlowProposalDetail,
FlowProposalStats,
ReviewProposalRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/flow-proposals", tags=["flow-proposals"])
# ── List proposals ──
@router.get("", response_model=list[FlowProposalSummary])
@limiter.limit("30/minute")
async def list_proposals(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
proposal_status: Optional[str] = Query(None, alias="status"),
proposal_type: Optional[str] = Query(None, alias="type"),
domain: Optional[str] = Query(None),
sort_by: str = Query("newest", pattern="^(newest|confidence|sessions)$"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
):
"""List flow proposals for the current user's account."""
if not current_user.account_id:
return []
query = (
select(FlowProposal)
.where(FlowProposal.account_id == current_user.account_id)
)
if proposal_status:
query = query.where(FlowProposal.status == proposal_status)
if proposal_type:
query = query.where(FlowProposal.proposal_type == proposal_type)
if domain:
query = query.where(FlowProposal.problem_domain == domain)
# Sorting
if sort_by == "confidence":
query = query.order_by(FlowProposal.confidence_score.desc())
elif sort_by == "sessions":
query = query.order_by(FlowProposal.supporting_session_count.desc())
else: # newest
query = query.order_by(FlowProposal.created_at.desc())
query = query.offset(skip).limit(limit)
result = await db.execute(query)
proposals = result.scalars().all()
return [FlowProposalSummary.model_validate(p) for p in proposals]
# ── Stats ──
@router.get("/stats", response_model=FlowProposalStats)
@limiter.limit("30/minute")
async def get_proposal_stats(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Get review queue dashboard stats."""
if not current_user.account_id:
return FlowProposalStats(
pending_count=0, approved_this_week=0, rejected_this_week=0,
auto_reinforced_this_week=0, top_domains=[],
)
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
# Count pending
pending_result = await db.execute(
select(func.count(FlowProposal.id))
.where(
FlowProposal.account_id == current_user.account_id,
FlowProposal.status == "pending",
)
)
pending_count = pending_result.scalar() or 0
# Reviewed this week (approved/rejected/modified use reviewed_at)
reviewed_result = await db.execute(
select(
FlowProposal.status,
func.count(FlowProposal.id),
)
.where(
FlowProposal.account_id == current_user.account_id,
FlowProposal.reviewed_at >= week_ago,
FlowProposal.status.in_(["approved", "modified", "rejected", "dismissed"]),
)
.group_by(FlowProposal.status)
)
reviewed_counts = {row[0]: row[1] for row in reviewed_result.all()}
# Auto-reinforced this week (use created_at since they have no review)
reinforced_result = await db.execute(
select(func.count(FlowProposal.id))
.where(
FlowProposal.account_id == current_user.account_id,
FlowProposal.created_at >= week_ago,
FlowProposal.status == "auto_reinforced",
)
)
auto_reinforced_count = reinforced_result.scalar() or 0
# Top domains
domain_result = await db.execute(
select(
FlowProposal.problem_domain,
func.count(FlowProposal.id).label("count"),
)
.where(
FlowProposal.account_id == current_user.account_id,
FlowProposal.status == "pending",
FlowProposal.problem_domain.isnot(None),
)
.group_by(FlowProposal.problem_domain)
.order_by(func.count(FlowProposal.id).desc())
.limit(5)
)
top_domains = [{"domain": row[0], "count": row[1]} for row in domain_result.all()]
return FlowProposalStats(
pending_count=pending_count,
approved_this_week=reviewed_counts.get("approved", 0) + reviewed_counts.get("modified", 0),
rejected_this_week=reviewed_counts.get("rejected", 0),
auto_reinforced_this_week=auto_reinforced_count,
top_domains=top_domains,
)
# ── Detail ──
@router.get("/{proposal_id}", response_model=FlowProposalDetail)
@limiter.limit("30/minute")
async def get_proposal(
request: Request,
proposal_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Get full proposal detail."""
result = await db.execute(
select(FlowProposal).where(
FlowProposal.id == proposal_id,
FlowProposal.account_id == current_user.account_id,
)
)
proposal = result.scalar_one_or_none()
if not proposal:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
return FlowProposalDetail.model_validate(proposal)
# ── Review ──
@router.post("/{proposal_id}/review", response_model=FlowProposalDetail)
@limiter.limit("10/minute")
async def review_proposal(
request: Request,
proposal_id: UUID,
data: ReviewProposalRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_team_admin),
):
"""Review a proposal: approve, reject, modify, or dismiss."""
result = await db.execute(
select(FlowProposal).where(
FlowProposal.id == proposal_id,
FlowProposal.account_id == current_user.account_id,
)
)
proposal = result.scalar_one_or_none()
if not proposal:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
if proposal.status not in ("pending", "dismissed"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot review proposal in status: {proposal.status}",
)
proposal.reviewed_by = current_user.id
proposal.reviewed_at = datetime.now(timezone.utc)
proposal.reviewer_notes = data.reviewer_notes
if data.action == "approve":
if proposal.proposal_type == "new_flow":
flow_data = proposal.proposed_flow_data
new_tree = await _create_tree_from_proposal(proposal, flow_data, current_user, db)
proposal.status = "approved"
proposal.published_flow_id = new_tree.id
elif proposal.proposal_type in ("enhancement", "branch_addition"):
# Enhancement proposals contain diffs, not complete tree structures.
# Direct approval requires modified_flow_data with the complete merged structure.
# Redirect reviewers to use "Edit & Publish" for enhancements.
if not data.modified_flow_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Enhancement proposals require 'Edit & Publish' to merge changes into the existing flow. Use the modify action with modified_flow_data.",
)
new_tree = await _create_tree_from_proposal(proposal, data.modified_flow_data, current_user, db)
proposal.status = "approved"
proposal.published_flow_id = new_tree.id
else:
# auto_reinforced shouldn't reach here, but handle gracefully
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot approve proposal of type: {proposal.proposal_type}",
)
elif data.action == "modify":
if not data.modified_flow_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="modified_flow_data is required for modify action",
)
new_tree = await _create_tree_from_proposal(proposal, data.modified_flow_data, current_user, db)
proposal.status = "modified"
proposal.published_flow_id = new_tree.id
elif data.action == "reject":
proposal.status = "rejected"
elif data.action == "dismiss":
proposal.status = "dismissed"
if data.action == "approve":
await notify("proposal.approved", proposal.account_id, {
"title": proposal.title,
"reviewer_name": current_user.display_name if hasattr(current_user, 'display_name') else current_user.email,
"link": "/review-queue",
}, db, target_user_ids=[proposal.created_by_id] if proposal.created_by_id else None)
await db.commit()
return FlowProposalDetail.model_validate(proposal)
async def _create_tree_from_proposal(
proposal: FlowProposal,
flow_data: dict,
user: User,
db: AsyncSession,
) -> Tree:
"""Create a new Tree from proposal flow data."""
tree_structure = flow_data.get("tree_structure", flow_data)
match_keywords = flow_data.get("match_keywords", [])
if not tree_structure or not isinstance(tree_structure, dict) or not tree_structure.get("id"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Proposal has no valid tree structure. Use 'Edit & Publish' to build the flow manually.",
)
new_tree = Tree(
id=uuid.uuid4(),
name=proposal.title,
description=proposal.description,
tree_type="troubleshooting",
tree_structure=tree_structure,
author_id=user.id,
account_id=proposal.account_id,
team_id=proposal.team_id,
origin="ai_generated" if proposal.proposal_type == "new_flow" else "ai_enhanced",
source_session_id=proposal.source_session_id,
match_keywords=match_keywords,
)
db.add(new_tree)
await db.flush()
logger.info(
"Created tree %s from proposal %s (%s)",
new_tree.id, proposal.id, proposal.proposal_type,
)
return new_tree

View File

@@ -0,0 +1,729 @@
"""FlowPilot Analytics API — MTTR, resolution rates, knowledge coverage.
Endpoints:
GET /analytics/flowpilot?period=30d — Main dashboard data
GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select, func, case, cast, Date, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_team_admin
from app.models.user import User
from app.models.tree import Tree
from app.models.ai_session import AISession
from app.models.flow_proposal import FlowProposal
from app.models.psa_activity_log import PsaActivityLog
from app.models.psa_post_log import PsaPostLog
from app.models.category import TreeCategory
from app.schemas.flowpilot_analytics import (
FlowPilotDashboard,
MTTRDataPoint,
DomainBreakdown,
ConfidenceBreakdown,
KnowledgeCoverage,
DomainCoverage,
PsaMetrics,
CoverageDomainRow,
CoverageResponse,
FlowQualityRow,
FlowQualityResponse,
EnhancedPsaMetrics,
PsaFunnel,
PsaDailyTrend,
)
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/analytics/flowpilot", tags=["flowpilot-analytics"])
def _get_period_start(period: str) -> datetime:
days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30)
return datetime.now(timezone.utc) - timedelta(days=days)
@router.get("", response_model=FlowPilotDashboard)
@limiter.limit("15/minute")
async def get_dashboard(
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)$"),
):
"""Get FlowPilot analytics dashboard data."""
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)
# ── Session counts ──
counts_result = await db.execute(
select(
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated"),
func.sum(case((AISession.status == "abandoned", 1), else_=0)).label("abandoned"),
func.avg(case((AISession.status == "resolved", AISession.step_count), else_=None)).label("avg_steps"),
func.avg(AISession.session_rating).label("avg_rating"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
)
)
row = counts_result.one()
total = int(row.total or 0)
resolved = int(row.resolved or 0)
escalated = int(row.escalated or 0)
abandoned = int(row.abandoned or 0)
avg_steps = float(row.avg_steps or 0)
avg_rating = float(row.avg_rating) if row.avg_rating else None
resolution_rate = (resolved / total * 100) if total > 0 else 0.0
# ── MTTR ──
mttr_result = await db.execute(
select(
func.avg(
extract("epoch", AISession.resolved_at - AISession.created_at) / 60
).label("avg_mttr"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status == "resolved",
AISession.resolved_at.isnot(None),
)
)
mttr_row = mttr_result.one()
mttr_minutes = float(mttr_row.avg_mttr) if mttr_row.avg_mttr else None
# ── Average duration ──
duration_result = await db.execute(
select(
func.avg(
extract("epoch", AISession.resolved_at - AISession.created_at) / 60
).label("avg_duration"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.resolved_at.isnot(None),
)
)
dur_row = duration_result.one()
avg_duration = float(dur_row.avg_duration) if dur_row.avg_duration else 0.0
# ── MTTR trend ──
mttr_trend_result = await db.execute(
select(
cast(AISession.resolved_at, Date).label("day"),
func.avg(
extract("epoch", AISession.resolved_at - AISession.created_at) / 60
).label("mttr"),
func.count(AISession.id).label("count"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status == "resolved",
AISession.resolved_at.isnot(None),
)
.group_by(cast(AISession.resolved_at, Date))
.order_by(cast(AISession.resolved_at, Date))
)
mttr_trend = [
MTTRDataPoint(
date=str(r.day),
mttr_minutes=round(float(r.mttr or 0), 1),
session_count=r.count,
)
for r in mttr_trend_result.all()
]
# ── Domain breakdown ──
domain_result = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
)
.group_by(AISession.problem_domain)
.order_by(func.count(AISession.id).desc())
)
sessions_by_domain = [
DomainBreakdown(
domain=r.problem_domain or "unknown",
total=int(r.total or 0),
resolved=int(r.resolved or 0),
escalated=int(r.escalated or 0),
resolution_rate=round(int(r.resolved or 0) / int(r.total) * 100, 1) if r.total else 0.0,
)
for r in domain_result.all()
]
# ── Confidence breakdown ──
confidence_result = await db.execute(
select(
AISession.confidence_tier,
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status.in_(["resolved", "escalated", "requesting_escalation"]),
)
.group_by(AISession.confidence_tier)
)
conf_data = {r.confidence_tier: (int(r.total or 0), int(r.resolved or 0)) for r in confidence_result.all()}
guided_total, guided_resolved = conf_data.get("guided", (0, 0))
exploring_total, exploring_resolved = conf_data.get("exploring", (0, 0))
discovery_total, discovery_resolved = conf_data.get("discovery", (0, 0))
confidence_breakdown = ConfidenceBreakdown(
guided_sessions=guided_total,
guided_resolution_rate=round(guided_resolved / guided_total * 100, 1) if guided_total > 0 else 0.0,
exploring_sessions=exploring_total,
exploring_resolution_rate=round(exploring_resolved / exploring_total * 100, 1) if exploring_total > 0 else 0.0,
discovery_sessions=discovery_total,
discovery_resolution_rate=round(discovery_resolved / discovery_total * 100, 1) if discovery_total > 0 else 0.0,
)
# ── Knowledge coverage ──
total_flows_result = await db.execute(
select(func.count(Tree.id)).where(Tree.account_id == account_id)
)
total_flows = total_flows_result.scalar() or 0
ai_flows_result = await db.execute(
select(func.count(Tree.id)).where(
Tree.account_id == account_id,
Tree.origin.in_(["ai_generated", "ai_enhanced"]),
)
)
ai_generated_flows = ai_flows_result.scalar() or 0
pending_proposals_result = await db.execute(
select(func.count(FlowProposal.id)).where(
FlowProposal.account_id == account_id,
FlowProposal.status == "pending",
)
)
total_proposals_pending = pending_proposals_result.scalar() or 0
approved_result = await db.execute(
select(func.count(FlowProposal.id)).where(
FlowProposal.account_id == account_id,
FlowProposal.reviewed_at >= period_start,
FlowProposal.status.in_(["approved", "modified"]),
)
)
proposals_approved = approved_result.scalar() or 0
rejected_result = await db.execute(
select(func.count(FlowProposal.id)).where(
FlowProposal.account_id == account_id,
FlowProposal.reviewed_at >= period_start,
FlowProposal.status == "rejected",
)
)
proposals_rejected = rejected_result.scalar() or 0
# Domain coverage
domain_coverage_result = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("session_count"),
func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided_count"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
)
.group_by(AISession.problem_domain)
)
# For now, flow_count per domain isn't directly available since Tree doesn't have problem_domain.
# Use match_keywords or just report 0. We'll improve this in Phase 4 with better flow categorization.
domain_cov_data = {}
for r in domain_coverage_result.all():
domain = r.problem_domain or "unknown"
sc = r.session_count or 0
gc = r.guided_count or 0
domain_cov_data[domain] = DomainCoverage(
domain=domain,
flow_count=0, # TODO: match via category/tags in Phase 4
session_count=sc,
guided_rate=round(gc / sc * 100, 1) if sc > 0 else 0.0,
)
knowledge_coverage = KnowledgeCoverage(
total_flows=total_flows,
ai_generated_flows=ai_generated_flows,
total_proposals_pending=total_proposals_pending,
proposals_approved_this_period=proposals_approved,
proposals_rejected_this_period=proposals_rejected,
coverage_by_domain=list(domain_cov_data.values()),
)
# ── PSA metrics ──
psa_metrics = None
psa_linked = await db.execute(
select(func.count(AISession.id)).where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.psa_ticket_id.isnot(None),
)
)
psa_linked_count = psa_linked.scalar() or 0
if psa_linked_count > 0 and total > 0:
psa_push_result = await db.execute(
select(
func.count(PsaPostLog.id).label("total_pushes"),
func.sum(case((PsaPostLog.status == "success", 1), else_=0)).label("first_success"),
func.sum(case(
((PsaPostLog.status == "success") & (PsaPostLog.retry_count > 0), 1),
else_=0
)).label("retry_success"),
)
.join(AISession, PsaPostLog.ai_session_id == AISession.id)
.where(
AISession.account_id == account_id,
PsaPostLog.ai_session_id.isnot(None),
PsaPostLog.posted_at >= period_start,
)
)
push_row = psa_push_result.one()
total_pushes = push_row.total_pushes or 0
first_success = push_row.first_success or 0
retry_success = push_row.retry_success or 0
psa_metrics = PsaMetrics(
ticket_link_rate=round(psa_linked_count / total * 100, 1),
auto_push_success_rate=round(first_success / total_pushes * 100, 1) if total_pushes > 0 else 0.0,
auto_push_retry_success_rate=round(retry_success / total_pushes * 100, 1) if total_pushes > 0 else 0.0,
total_time_entries_logged=0, # TODO: track from CW time entries
total_hours_logged=0.0,
)
return FlowPilotDashboard(
period=period,
total_sessions=total,
resolved_sessions=resolved,
escalated_sessions=escalated,
abandoned_sessions=abandoned,
resolution_rate=round(resolution_rate, 1),
avg_steps_to_resolution=round(avg_steps, 1),
avg_session_duration_minutes=round(avg_duration, 1),
avg_rating=round(avg_rating, 2) if avg_rating else None,
mttr_minutes=round(mttr_minutes, 1) if mttr_minutes else None,
mttr_trend=mttr_trend,
sessions_by_domain=sessions_by_domain,
confidence_breakdown=confidence_breakdown,
knowledge_coverage=knowledge_coverage,
psa_metrics=psa_metrics,
)
@router.get("/knowledge-gaps", response_model=KnowledgeGapReport)
@limiter.limit("10/minute")
async def get_knowledge_gaps_endpoint(
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)$"),
):
"""Get knowledge gap analysis report."""
if not current_user.account_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account")
days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30)
return await get_knowledge_gaps(current_user.account_id, db, period_days=days)
@router.get("/coverage", response_model=CoverageResponse)
@limiter.limit("15/minute")
async def get_coverage_heatmap(
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)$"),
):
"""Get coverage heatmap: sessions and flow coverage broken down by problem domain."""
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)
# ── Session stats per domain ──
domain_stats_result = 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_count"),
func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated_count"),
func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided_count"),
func.avg(
case(
(
(AISession.status == "resolved") & AISession.resolved_at.isnot(None),
extract("epoch", AISession.resolved_at - AISession.created_at) / 60,
),
else_=None,
)
).label("avg_resolution_minutes"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
)
.group_by(AISession.problem_domain)
.order_by(func.count(AISession.id).desc())
)
domain_rows = domain_stats_result.all()
# ── 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_session_count = int(unmapped_result.scalar() or 0)
# ── Flow counts per domain: match Category.name to problem_domain ──
# Joins Tree → TreeCategory and groups by lowercased category name for case-insensitive matching
flow_counts_result = await db.execute(
select(
func.lower(TreeCategory.name).label("domain"),
func.count(Tree.id).label("flow_count"),
)
.join(Tree, Tree.category_id == TreeCategory.id)
.where(
Tree.account_id == account_id,
Tree.is_active.is_(True),
Tree.deleted_at.is_(None),
)
.group_by(func.lower(TreeCategory.name))
)
flow_counts_by_domain: dict[str, int] = {
r.domain: int(r.flow_count) for r in flow_counts_result.all()
}
domains = []
for r in domain_rows:
sc = int(r.session_count or 0)
resolved = int(r.resolved_count or 0)
escalated = int(r.escalated_count or 0)
guided = int(r.guided_count or 0)
domain_name = r.problem_domain or "unknown"
avg_res = float(r.avg_resolution_minutes) if r.avg_resolution_minutes is not None else None
domains.append(
CoverageDomainRow(
domain=domain_name,
flow_count=flow_counts_by_domain.get(domain_name.lower(), 0),
session_count=sc,
resolution_rate=round(resolved / sc, 4) if sc > 0 else 0.0,
escalation_rate=round(escalated / sc, 4) if sc > 0 else 0.0,
guided_rate=round(guided / sc, 4) if sc > 0 else 0.0,
avg_resolution_minutes=round(avg_res, 1) if avg_res is not None else None,
)
)
return CoverageResponse(
domains=domains,
unmapped_session_count=unmapped_session_count,
total_domains=len(domains),
)
@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)$"),
):
"""Get flow quality scoring for all active flows in the account."""
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)
now = datetime.now(timezone.utc)
# ── Get all active flows (only needed columns — avoids loading large tree_structure JSONB) ──
flows_result = await db.execute(
select(Tree.id, Tree.name, Tree.tree_type).where(
Tree.account_id == account_id,
Tree.is_active.is_(True),
Tree.deleted_at.is_(None),
)
)
flows = flows_result.all()
if not flows:
return FlowQualityResponse(flows=[], top_performers=[], needs_attention=[])
flow_ids = [f.id for f in flows]
# ── Session stats per flow within the period ──
session_stats_result = await db.execute(
select(
AISession.matched_flow_id,
func.count(AISession.id).label("total"),
func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"),
func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided"),
func.max(AISession.created_at).label("last_matched_at"),
)
.where(
AISession.account_id == account_id,
AISession.matched_flow_id.in_(flow_ids),
AISession.created_at >= period_start,
)
.group_by(AISession.matched_flow_id)
)
stats_by_flow: dict = {}
for r in session_stats_result.all():
stats_by_flow[r.matched_flow_id] = {
"total": int(r.total or 0),
"resolved": int(r.resolved or 0),
"guided": int(r.guided or 0),
"last_matched_at": r.last_matched_at,
}
# ── Also get the most recent match ever (for recency score, regardless of period) ──
recent_match_result = await db.execute(
select(
AISession.matched_flow_id,
func.max(AISession.created_at).label("last_ever"),
)
.where(
AISession.account_id == account_id,
AISession.matched_flow_id.in_(flow_ids),
)
.group_by(AISession.matched_flow_id)
)
last_ever_by_flow: dict = {r.matched_flow_id: r.last_ever for r in recent_match_result.all()}
# ── Build scored rows ──
scored_rows: list[FlowQualityRow] = []
for flow in flows:
stats = stats_by_flow.get(flow.id)
last_ever = last_ever_by_flow.get(flow.id)
if stats and stats["total"] > 0:
total = stats["total"]
resolved = stats["resolved"]
guided = stats["guided"]
success_rate = resolved / total
guided_rate = guided / total
# Recency score based on last match ever
if last_ever is not None:
last_ever_aware = last_ever.replace(tzinfo=timezone.utc) if last_ever.tzinfo is None else last_ever
days_since = (now - last_ever_aware).total_seconds() / 86400
recency_score = max(0.0, min(1.0, 1.0 - days_since / 90.0))
else:
recency_score = 0.0
quality_score = round(
(success_rate * 0.5) + (guided_rate * 0.3) + (recency_score * 0.2),
4,
)
avg_confidence = round(guided_rate, 4) # guided_rate as confidence proxy
last_matched_at = stats.get("last_matched_at")
else:
success_rate = None
avg_confidence = None
quality_score = 0.0
last_matched_at = last_ever # may be None
if last_matched_at is not None and last_matched_at.tzinfo is None:
last_matched_at = last_matched_at.replace(tzinfo=timezone.utc)
scored_rows.append(
FlowQualityRow(
flow_id=str(flow.id),
name=flow.name,
tree_type=flow.tree_type,
usage_count=stats["total"] if stats else 0,
success_rate=round(success_rate, 4) if success_rate is not None else None,
last_matched_at=last_matched_at,
avg_confidence=avg_confidence,
quality_score=quality_score,
)
)
# ── Sort ──
if sort == "usage":
scored_rows.sort(key=lambda r: r.usage_count, reverse=True)
elif sort == "success_rate":
scored_rows.sort(key=lambda r: (r.success_rate is not None, r.success_rate or 0.0), reverse=True)
else:
scored_rows.sort(key=lambda r: r.quality_score, reverse=True)
# ── Top performers: top 5 by quality_score with usage > 0 ──
top_performers = [r for r in scored_rows if r.usage_count > 0]
top_performers = sorted(top_performers, key=lambda r: r.quality_score, reverse=True)[:5]
# ── Needs attention: used at least once, AND (success_rate < 0.5 OR not used in 30+ days) ──
thirty_days_ago = now - timedelta(days=30)
needs_attention = []
for r in scored_rows:
if r.usage_count == 0:
continue
low_success = r.success_rate is not None and r.success_rate < 0.5
stale = r.last_matched_at is not None and r.last_matched_at < thirty_days_ago
if low_success or stale:
needs_attention.append(r)
return FlowQualityResponse(
flows=scored_rows,
top_performers=top_performers,
needs_attention=needs_attention,
)
@router.get("/psa-metrics", response_model=EnhancedPsaMetrics)
@limiter.limit("15/minute")
async def get_enhanced_psa_metrics(
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)$"),
):
"""Get enhanced PSA integration metrics including time entry stats, push funnel, and daily trend."""
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)
# ── Time entry totals from psa_activity_logs ──
time_entry_result = await db.execute(
select(
func.count(PsaActivityLog.id).label("entry_count"),
func.sum(PsaActivityLog.hours_logged).label("total_hours"),
)
.where(
PsaActivityLog.account_id == account_id,
PsaActivityLog.activity_type == "time_entry_posted",
PsaActivityLog.created_at >= period_start,
)
)
te_row = time_entry_result.one()
total_time_entries = int(te_row.entry_count or 0)
total_hours_raw = te_row.total_hours or 0
total_hours_logged = round(float(total_hours_raw), 2)
avg_hours = round(total_hours_logged / total_time_entries, 2) if total_time_entries > 0 else 0.0
# ── Push funnel ──
# Total sessions in period
total_sessions_result = await db.execute(
select(func.count(AISession.id)).where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
)
)
total_sessions = int(total_sessions_result.scalar() or 0)
# Sessions linked to a ticket
linked_result = await db.execute(
select(func.count(AISession.id)).where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.psa_ticket_id.isnot(None),
)
)
linked_to_ticket = int(linked_result.scalar() or 0)
# Sessions with a successful doc push (via PsaPostLog) — count unique sessions, not log entries
doc_pushed_result = await db.execute(
select(func.count(PsaPostLog.ai_session_id.distinct()))
.join(AISession, PsaPostLog.ai_session_id == AISession.id)
.where(
AISession.account_id == account_id,
PsaPostLog.ai_session_id.isnot(None),
PsaPostLog.status == "success",
PsaPostLog.posted_at >= period_start,
)
)
doc_pushed = int(doc_pushed_result.scalar() or 0)
# Sessions with a time entry logged (via psa_activity_logs)
time_entry_sessions_result = await db.execute(
select(func.count(PsaActivityLog.session_id.distinct())).where(
PsaActivityLog.account_id == account_id,
PsaActivityLog.activity_type == "time_entry_posted",
PsaActivityLog.created_at >= period_start,
PsaActivityLog.session_id.isnot(None),
)
)
time_entry_logged = int(time_entry_sessions_result.scalar() or 0)
push_funnel = PsaFunnel(
total_sessions=total_sessions,
linked_to_ticket=linked_to_ticket,
doc_pushed=doc_pushed,
time_entry_logged=time_entry_logged,
)
# ── Daily trend (time entries grouped by date) ──
daily_result = await db.execute(
select(
cast(PsaActivityLog.created_at, Date).label("day"),
func.count(PsaActivityLog.id).label("entries"),
func.sum(PsaActivityLog.hours_logged).label("hours"),
)
.where(
PsaActivityLog.account_id == account_id,
PsaActivityLog.activity_type == "time_entry_posted",
PsaActivityLog.created_at >= period_start,
)
.group_by(cast(PsaActivityLog.created_at, Date))
.order_by(cast(PsaActivityLog.created_at, Date))
)
daily_trend = [
PsaDailyTrend(
date=str(r.day),
entries=int(r.entries or 0),
hours=round(float(r.hours or 0), 2),
)
for r in daily_result.all()
]
return EnhancedPsaMetrics(
total_time_entries=total_time_entries,
total_hours_logged=total_hours_logged,
avg_hours_per_session=avg_hours,
push_funnel=push_funnel,
daily_trend=daily_trend,
)

View File

@@ -279,6 +279,69 @@ async def test_connection(
return result return result
# ── FlowPilot PSA Settings ──────────────────────────────────────
@router.get("/connections/{connection_id}/flowpilot-settings")
async def get_flowpilot_settings(
connection_id: UUID,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get FlowPilot-specific settings for a PSA connection."""
conn = await _get_connection_or_404(connection_id, current_user, db)
# Return settings with defaults filled in
defaults = {
"auto_push": True,
"auto_time_entry": True,
"time_rounding": "15min",
"note_visibility": "internal",
"include_diagnostic_steps": True,
"prompt_status_on_resolution": False,
"prompt_status_on_escalation": False,
}
settings_data = {**defaults, **(conn.flowpilot_settings or {})}
return settings_data
@router.put("/connections/{connection_id}/flowpilot-settings")
async def update_flowpilot_settings(
connection_id: UUID,
data: dict,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update FlowPilot-specific settings for a PSA connection."""
conn = await _get_connection_or_404(connection_id, current_user, db)
# Validate allowed keys
allowed_keys = {
"auto_push", "auto_time_entry", "time_rounding",
"note_visibility", "include_diagnostic_steps",
"prompt_status_on_resolution", "prompt_status_on_escalation",
}
filtered = {k: v for k, v in data.items() if k in allowed_keys}
# Merge with existing
current = conn.flowpilot_settings or {}
current.update(filtered)
conn.flowpilot_settings = current
await db.commit()
await db.refresh(conn)
defaults = {
"auto_push": True,
"auto_time_entry": True,
"time_rounding": "15min",
"note_visibility": "internal",
"include_diagnostic_steps": True,
"prompt_status_on_resolution": False,
"prompt_status_on_escalation": False,
}
return {**defaults, **(conn.flowpilot_settings or {})}
# ── ticket / status / company endpoints ────────────────────────── # ── ticket / status / company endpoints ──────────────────────────

View File

@@ -0,0 +1,255 @@
"""Notification endpoints — config CRUD + in-app notification management.
Config CRUD (team_admin):
GET /notifications/configs — List configs for account
POST /notifications/configs — Create config
PATCH /notifications/configs/{id} — Update config
DELETE /notifications/configs/{id} — Delete config
POST /notifications/configs/test — Test a config
In-app notifications (any authenticated user):
GET /notifications — List notifications (paginated)
GET /notifications/unread-count — Unread count
PATCH /notifications/{id}/read — Mark one as read
POST /notifications/mark-all-read — Mark all as read
"""
import logging
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, require_team_admin
from app.core.database import get_db
from app.models.user import User
from app.models.notification_config import NotificationConfig
from app.models.notification import Notification
from app.schemas.notification import (
NotificationConfigCreate,
NotificationConfigUpdate,
NotificationConfigResponse,
NotificationResponse,
UnreadCountResponse,
NotificationTestRequest,
NotificationTestResponse,
)
from app.services.notification_service import send_test_notification
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
# ---------------------------------------------------------------------------
# Config CRUD (team_admin required)
# ---------------------------------------------------------------------------
@router.get("/configs", response_model=list[NotificationConfigResponse])
@limiter.limit("30/minute")
async def list_configs(
request: Request,
current_user: Annotated[User, Depends(require_team_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List all notification configs for the current account."""
result = await db.execute(
select(NotificationConfig)
.where(NotificationConfig.account_id == current_user.account_id)
.order_by(NotificationConfig.created_at.desc())
)
return result.scalars().all()
@router.post("/configs", response_model=NotificationConfigResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute")
async def create_config(
request: Request,
body: NotificationConfigCreate,
current_user: Annotated[User, Depends(require_team_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new notification config."""
# Validate channel-specific requirements
if body.channel in ("slack_webhook", "teams_webhook") and not body.webhook_url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"webhook_url is required for {body.channel} channel",
)
if body.channel == "email" and not body.email_addresses:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="email_addresses is required for email channel",
)
config = NotificationConfig(
account_id=current_user.account_id,
channel=body.channel,
webhook_url=body.webhook_url,
email_addresses=body.email_addresses,
events_enabled=body.events_enabled,
)
db.add(config)
await db.commit()
await db.refresh(config)
return config
@router.patch("/configs/{config_id}", response_model=NotificationConfigResponse)
@limiter.limit("20/minute")
async def update_config(
request: Request,
config_id: UUID,
body: NotificationConfigUpdate,
current_user: Annotated[User, Depends(require_team_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update an existing notification config."""
result = await db.execute(
select(NotificationConfig)
.where(NotificationConfig.id == config_id)
.where(NotificationConfig.account_id == current_user.account_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(config, field, value)
await db.commit()
await db.refresh(config)
return config
@router.delete("/configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("10/minute")
async def delete_config(
request: Request,
config_id: UUID,
current_user: Annotated[User, Depends(require_team_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a notification config."""
result = await db.execute(
select(NotificationConfig)
.where(NotificationConfig.id == config_id)
.where(NotificationConfig.account_id == current_user.account_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
await db.delete(config)
await db.commit()
@router.post("/configs/test", response_model=NotificationTestResponse)
@limiter.limit("5/minute")
async def test_config(
request: Request,
body: NotificationTestRequest,
current_user: Annotated[User, Depends(require_team_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Send a test notification through a config."""
result = await db.execute(
select(NotificationConfig)
.where(NotificationConfig.id == body.config_id)
.where(NotificationConfig.account_id == current_user.account_id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
success, message = await send_test_notification(config)
return NotificationTestResponse(success=success, message=message)
# ---------------------------------------------------------------------------
# In-app notifications (any authenticated user)
# ---------------------------------------------------------------------------
@router.get("", response_model=list[NotificationResponse])
@limiter.limit("60/minute")
async def list_notifications(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List notifications for the current user, unread first."""
result = await db.execute(
select(Notification)
.where(Notification.user_id == current_user.id)
.order_by(Notification.is_read.asc(), Notification.created_at.desc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
@router.get("/unread-count", response_model=UnreadCountResponse)
@limiter.limit("120/minute")
async def unread_count(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get count of unread notifications for the current user."""
result = await db.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == current_user.id)
.where(Notification.is_read.is_(False))
)
count = result.scalar_one()
return UnreadCountResponse(count=count)
@router.patch("/{notification_id}/read", response_model=NotificationResponse)
@limiter.limit("60/minute")
async def mark_read(
request: Request,
notification_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Mark a single notification as read."""
result = await db.execute(
select(Notification)
.where(Notification.id == notification_id)
.where(Notification.user_id == current_user.id)
)
notification = result.scalar_one_or_none()
if not notification:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
notification.is_read = True
await db.commit()
await db.refresh(notification)
return notification
@router.post("/mark-all-read", response_model=UnreadCountResponse)
@limiter.limit("10/minute")
async def mark_all_read(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Mark all notifications as read for the current user."""
await db.execute(
update(Notification)
.where(Notification.user_id == current_user.id)
.where(Notification.is_read.is_(False))
.values(is_read=True)
)
await db.commit()
return UnreadCountResponse(count=0)

View File

@@ -0,0 +1,458 @@
"""Public templates gallery endpoints. No authentication required."""
import logging
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.rate_limit import limiter
from app.models.category import TreeCategory
from app.models.script_template import ScriptCategory, ScriptTemplate
from app.models.tag import TreeTag
from app.models.tree import Tree
from app.schemas.public_templates import (
PublicFlowDetail,
PublicFlowTemplate,
PublicGalleryResponse,
PublicScriptDetail,
PublicScriptTemplate,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/public/templates", tags=["public-gallery"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _count_tree_steps(structure: dict[str, Any] | None) -> int:
"""Count nodes in a tree structure via BFS."""
if not structure:
return 0
count = 0
queue = [structure]
while queue:
node = queue.pop()
count += 1
for child in node.get("children", []):
queue.append(child)
for option in node.get("options", []):
if isinstance(option, dict) and "children" in option:
for child in option["children"]:
queue.append(child)
return count
def _truncate_structure(structure: dict[str, Any] | None, max_depth: int = 3) -> dict[str, Any] | None:
"""Return a copy of the tree structure truncated to max_depth levels."""
if not structure:
return None
def _truncate_node(node: dict[str, Any], depth: int) -> dict[str, Any]:
if depth >= max_depth:
# Return node skeleton without children
return {k: v for k, v in node.items() if k not in ("children", "options")}
result = {k: v for k, v in node.items() if k not in ("children", "options")}
if "children" in node:
result["children"] = [_truncate_node(c, depth + 1) for c in node["children"]]
if "options" in node:
truncated_options = []
for opt in node["options"]:
if isinstance(opt, dict):
opt_copy = {k: v for k, v in opt.items() if k != "children"}
if "children" in opt:
opt_copy["children"] = [_truncate_node(c, depth + 1) for c in opt["children"]]
truncated_options.append(opt_copy)
else:
truncated_options.append(opt)
result["options"] = truncated_options
return result
return _truncate_node(structure, 0)
def _build_flow_template(tree: Tree) -> PublicFlowTemplate:
category_name = None
if tree.category_rel:
category_name = tree.category_rel.name
elif tree.category:
category_name = tree.category
tag_names = [tag.name for tag in (tree.tags or [])]
return PublicFlowTemplate(
id=tree.id,
name=tree.name,
description=tree.description,
category=category_name,
tree_type=tree.tree_type,
step_count=_count_tree_steps(tree.tree_structure),
usage_count=tree.usage_count,
success_rate=tree.success_rate,
tags=tag_names,
preview_structure=_truncate_structure(tree.tree_structure),
created_at=tree.created_at,
)
def _build_script_template(script: ScriptTemplate) -> PublicScriptTemplate:
param_count = 0
if isinstance(script.parameters_schema, dict):
params = script.parameters_schema.get("parameters", script.parameters_schema.get("properties", {}))
if isinstance(params, list):
param_count = len(params)
elif isinstance(params, dict):
param_count = len(params)
return PublicScriptTemplate(
id=script.id,
name=script.name,
description=script.description,
category_name=script.category.name if script.category else None,
category_icon=script.category.icon if script.category else None,
complexity=script.complexity,
tags=list(script.tags) if script.tags else [],
parameter_count=param_count,
requires_elevation=script.requires_elevation,
requires_modules=list(script.requires_modules) if script.requires_modules else [],
usage_count=script.usage_count,
is_verified=script.is_verified,
created_at=script.created_at,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=PublicGalleryResponse)
@limiter.limit("30/minute")
async def list_gallery(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
category: str | None = Query(None, description="Filter by category name"),
type: str = Query("all", description="Filter type: flows, scripts, or all"),
sort: str = Query("usage", description="Sort order: usage, newest, success_rate"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
"""Public gallery listing. Returns featured flows and scripts.
No authentication required. Rate limited to 30/minute.
"""
offset = (page - 1) * per_page
flow_templates: list[PublicFlowTemplate] = []
script_templates: list[PublicScriptTemplate] = []
total_flows = 0
total_scripts = 0
category_names: set[str] = set()
domains: list[str] = []
# --- Flow templates ---
if type in ("all", "flows"):
q = (
select(Tree)
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
.where(Tree.is_gallery_featured == True) # noqa: E712
.where(Tree.is_active == True) # noqa: E712
.where(Tree.deleted_at == None) # noqa: E711
)
if category:
q = q.join(TreeCategory, Tree.category_id == TreeCategory.id, isouter=True).where(
or_(TreeCategory.name == category, Tree.category == category)
)
# Count query
count_q = select(func.count()).select_from(q.subquery())
total_flows = (await db.execute(count_q)).scalar_one()
# Sort
if sort == "newest":
q = q.order_by(Tree.created_at.desc())
elif sort == "success_rate":
q = q.order_by(Tree.success_rate.desc().nulls_last(), Tree.usage_count.desc())
else: # usage (default)
q = q.order_by(Tree.usage_count.desc(), Tree.gallery_sort_order.asc())
q = q.offset(offset).limit(per_page)
result = await db.execute(q)
trees = result.scalars().all()
for tree in trees:
flow_templates.append(_build_flow_template(tree))
cat = None
if tree.category_rel:
cat = tree.category_rel.name
elif tree.category:
cat = tree.category
if cat:
category_names.add(cat)
# --- Script templates ---
if type in ("all", "scripts"):
sq = (
select(ScriptTemplate)
.options(selectinload(ScriptTemplate.category))
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
.where(ScriptTemplate.is_active == True) # noqa: E712
)
if category:
sq = sq.join(ScriptCategory, ScriptTemplate.category_id == ScriptCategory.id).where(
ScriptCategory.name == category
)
count_sq = select(func.count()).select_from(sq.subquery())
total_scripts = (await db.execute(count_sq)).scalar_one()
if sort == "newest":
sq = sq.order_by(ScriptTemplate.created_at.desc())
elif sort == "success_rate":
sq = sq.order_by(ScriptTemplate.usage_count.desc())
else:
sq = sq.order_by(ScriptTemplate.usage_count.desc(), ScriptTemplate.gallery_sort_order.asc())
sq = sq.offset(offset).limit(per_page)
result = await db.execute(sq)
scripts = result.scalars().all()
for script in scripts:
script_templates.append(_build_script_template(script))
if script.category:
category_names.add(script.category.name)
return PublicGalleryResponse(
flow_templates=flow_templates,
script_templates=script_templates,
total_flows=total_flows,
total_scripts=total_scripts,
categories=sorted(category_names),
domains=domains,
)
@router.get("/flows/{flow_id}", response_model=PublicFlowDetail)
@limiter.limit("30/minute")
async def get_flow_detail(
request: Request,
flow_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a single featured flow template (preview only — tree truncated to 3 levels).
No authentication required. Script body is never exposed.
"""
result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
.where(Tree.id == flow_id)
.where(Tree.is_gallery_featured == True) # noqa: E712
.where(Tree.is_active == True) # noqa: E712
.where(Tree.deleted_at == None) # noqa: E711
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Flow template not found")
return _build_flow_template(tree)
@router.get("/scripts/{script_id}", response_model=PublicScriptDetail)
@limiter.limit("30/minute")
async def get_script_detail(
request: Request,
script_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a single featured script template detail.
NOTE: script_body is NEVER returned — it is behind the signup wall.
Only parameter names/descriptions are included.
"""
result = await db.execute(
select(ScriptTemplate)
.options(selectinload(ScriptTemplate.category))
.where(ScriptTemplate.id == script_id)
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
.where(ScriptTemplate.is_active == True) # noqa: E712
)
script = result.scalar_one_or_none()
if not script:
raise HTTPException(status_code=404, detail="Script template not found")
# Build safe parameter list (no script_body — that stays locked behind auth)
safe_params: list[dict[str, Any]] = []
schema = script.parameters_schema or {}
raw_params = schema.get("parameters", schema.get("properties", {}))
if isinstance(raw_params, list):
for p in raw_params:
if isinstance(p, dict):
safe_params.append({
"name": p.get("name", ""),
"description": p.get("description", ""),
"type": p.get("type", "string"),
"required": p.get("required", False),
"default": p.get("default"),
})
elif isinstance(raw_params, dict):
for param_name, param_def in raw_params.items():
if isinstance(param_def, dict):
safe_params.append({
"name": param_name,
"description": param_def.get("description", ""),
"type": param_def.get("type", "string"),
"required": param_def.get("required", False),
"default": param_def.get("default"),
})
return PublicScriptDetail(
id=script.id,
name=script.name,
description=script.description,
category_name=script.category.name if script.category else None,
complexity=script.complexity,
tags=list(script.tags) if script.tags else [],
parameters=safe_params,
requires_elevation=script.requires_elevation,
requires_modules=list(script.requires_modules) if script.requires_modules else [],
usage_count=script.usage_count,
is_verified=script.is_verified,
created_at=script.created_at,
)
@router.get("/categories")
@limiter.limit("30/minute")
async def list_categories(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Return categories that have at least one gallery-featured item, with counts.
No authentication required.
"""
# Flow categories
flow_cat_result = await db.execute(
select(TreeCategory.name, func.count(Tree.id).label("flow_count"))
.join(Tree, Tree.category_id == TreeCategory.id)
.where(Tree.is_gallery_featured == True) # noqa: E712
.where(Tree.is_active == True) # noqa: E712
.where(Tree.deleted_at == None) # noqa: E711
.group_by(TreeCategory.name)
.order_by(TreeCategory.name)
)
flow_cats = flow_cat_result.all()
# Script categories
script_cat_result = await db.execute(
select(ScriptCategory.name, func.count(ScriptTemplate.id).label("script_count"))
.join(ScriptTemplate, ScriptTemplate.category_id == ScriptCategory.id)
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
.where(ScriptTemplate.is_active == True) # noqa: E712
.group_by(ScriptCategory.name)
.order_by(ScriptCategory.name)
)
script_cats = script_cat_result.all()
categories = []
seen: set[str] = set()
for name, flow_count in flow_cats:
if name not in seen:
categories.append({"name": name, "flow_count": flow_count, "script_count": 0})
seen.add(name)
for name, script_count in script_cats:
if name in seen:
for cat in categories:
if cat["name"] == name:
cat["script_count"] = script_count
break
else:
categories.append({"name": name, "flow_count": 0, "script_count": script_count})
seen.add(name)
return {"categories": categories}
@router.get("/search")
@limiter.limit("30/minute")
async def search_gallery(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
q: str = Query(..., min_length=1, max_length=200, description="Search query"),
type: str = Query("all", description="Filter type: flows, scripts, or all"),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
):
"""Full-text search across featured gallery items.
Searches name, description, and tags. No authentication required.
"""
offset = (page - 1) * per_page
search_term = f"%{q.lower()}%"
flow_templates: list[PublicFlowTemplate] = []
script_templates: list[PublicScriptTemplate] = []
total_flows = 0
total_scripts = 0
if type in ("all", "flows"):
flow_q = (
select(Tree)
.options(selectinload(Tree.category_rel), selectinload(Tree.tags))
.where(Tree.is_gallery_featured == True) # noqa: E712
.where(Tree.is_active == True) # noqa: E712
.where(Tree.deleted_at == None) # noqa: E711
.where(
or_(
func.lower(Tree.name).like(search_term),
func.lower(Tree.description).like(search_term),
func.lower(Tree.category).like(search_term),
)
)
.order_by(Tree.usage_count.desc())
)
count_q = select(func.count()).select_from(flow_q.subquery())
total_flows = (await db.execute(count_q)).scalar_one()
result = await db.execute(flow_q.offset(offset).limit(per_page))
for tree in result.scalars().all():
flow_templates.append(_build_flow_template(tree))
if type in ("all", "scripts"):
script_q = (
select(ScriptTemplate)
.options(selectinload(ScriptTemplate.category))
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
.where(ScriptTemplate.is_active == True) # noqa: E712
.where(
or_(
func.lower(ScriptTemplate.name).like(search_term),
func.lower(ScriptTemplate.description).like(search_term),
)
)
.order_by(ScriptTemplate.usage_count.desc())
)
count_sq = select(func.count()).select_from(script_q.subquery())
total_scripts = (await db.execute(count_sq)).scalar_one()
result = await db.execute(script_q.offset(offset).limit(per_page))
for script in result.scalars().all():
script_templates.append(_build_script_template(script))
return {
"flow_templates": flow_templates,
"script_templates": script_templates,
"total_flows": total_flows,
"total_scripts": total_scripts,
"query": q,
}

View File

@@ -355,6 +355,7 @@ async def generate_script(
user_id=current_user.id, user_id=current_user.id,
team_id=current_user.team_id, team_id=current_user.team_id,
session_id=data.session_id, session_id=data.session_id,
ai_session_id=data.ai_session_id,
parameters_used=redacted_params, parameters_used=redacted_params,
generated_script=rendered_script, generated_script=rendered_script,
) )

View File

@@ -424,18 +424,43 @@ async def export_session(
for sd in sd_result.scalars().all() for sd in sd_result.scalars().all()
] ]
# Query file upload evidence for non-PDF formats
from app.models.file_upload import FileUpload
from app.services import storage_service as _storage_service
from app.core.config import settings as _export_settings
upload_items: list[dict] = []
if _export_settings.STORAGE_ENDPOINT:
try:
uploads_result = await db.execute(
select(FileUpload)
.where(FileUpload.session_id == session_id)
.order_by(FileUpload.created_at)
)
for u in uploads_result.scalars().all():
try:
url = _storage_service.get_presigned_url(u.storage_key)
upload_items.append({
"filename": u.filename,
"url": url,
"is_image": u.content_type.startswith("image/"),
})
except Exception:
pass # Skip uploads that fail URL generation
except Exception:
pass # Storage errors should not fail the export
# Generate export based on format # Generate export based on format
if export_options.format == "markdown": if export_options.format == "markdown":
content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items) content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/markdown" media_type = "text/markdown"
elif export_options.format == "html": elif export_options.format == "html":
content = generate_html_export(session, export_options, supporting_data=supporting_data_items) content = generate_html_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/html" media_type = "text/html"
elif export_options.format == "psa": elif export_options.format == "psa":
content = generate_psa_export(session, export_options, supporting_data=supporting_data_items) content = generate_psa_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/plain" media_type = "text/plain"
else: # text else: # text
content = generate_text_export(session, export_options, supporting_data=supporting_data_items) content = generate_text_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items)
media_type = "text/plain" media_type = "text/plain"
# Resolve variables in export output # Resolve variables in export output

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.api.deps import get_current_active_user from app.api.deps import get_current_active_user
from app.models.ai_session import AISession
from app.models.session import Session from app.models.session import Session
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User from app.models.user import User
@@ -146,6 +147,26 @@ async def get_sidebar_stats(
for row in recent_result.all() for row in recent_result.all()
] ]
# --- Escalation count (team/account-wide pending escalations) ---
escalation_count = 0
if current_user.team_id:
esc_scope = AISession.team_id == current_user.team_id
elif current_user.account_id:
esc_scope = AISession.account_id == current_user.account_id
else:
esc_scope = None
if esc_scope is not None:
esc_result = await db.execute(
select(func.count()).where(
and_(
esc_scope,
AISession.status == "requesting_escalation",
)
)
)
escalation_count = esc_result.scalar() or 0
# --- Tree counts (for All Flows sub-items) --- # --- Tree counts (for All Flows sub-items) ---
tree_counts_result = await db.execute( tree_counts_result = await db.execute(
select( select(
@@ -167,6 +188,7 @@ async def get_sidebar_stats(
resolved_today=resolved_today, resolved_today=resolved_today,
active_count=active_count, active_count=active_count,
total_session_minutes_today=total_minutes, total_session_minutes_today=total_minutes,
escalation_count=escalation_count,
tree_counts=SidebarTreeCounts( tree_counts=SidebarTreeCounts(
total=tc.total, total=tc.total,
troubleshooting=tc.troubleshooting, troubleshooting=tc.troubleshooting,

View File

@@ -0,0 +1,208 @@
"""File upload endpoints — S3-compatible object storage.
POST /uploads — Upload a file (multipart)
GET /uploads/{id}/url — Get presigned download URL
GET /uploads — List uploads for a session
DELETE /uploads/{id} — Delete an upload
"""
import logging
from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_active_user, get_db
from app.core.config import settings
from app.core.rate_limit import limiter
from app.models.file_upload import FileUpload
from app.models.user import User
from app.schemas.upload import FileUploadResponse
from app.services import storage_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/uploads", tags=["uploads"])
def _check_storage_configured() -> None:
"""Raise 503 if object storage is not configured."""
if not settings.STORAGE_ENDPOINT:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="File storage is not configured",
)
@router.post("", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute")
async def upload_file(
request: Request,
file: UploadFile = File(...),
session_id: Optional[str] = Form(None),
current_user: Annotated[User, Depends(get_current_active_user)] = None,
db: Annotated[AsyncSession, Depends(get_db)] = None,
) -> FileUploadResponse:
"""Upload a file and store it in S3-compatible object storage."""
_check_storage_configured()
file_data = await file.read()
content_type = file.content_type or "application/octet-stream"
size_bytes = len(file_data)
# Validate content type and size
error = storage_service.validate_upload(content_type, size_bytes)
if error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
# Parse and validate session_id if provided
parsed_session_id: Optional[UUID] = None
if session_id:
try:
parsed_session_id = UUID(session_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid session_id"
)
# Check per-session limits
count_result = await db.execute(
select(func.count()).select_from(FileUpload).where(
FileUpload.session_id == parsed_session_id
)
)
session_count = count_result.scalar() or 0
if session_count >= storage_service.MAX_FILES_PER_SESSION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Session has reached the maximum of {storage_service.MAX_FILES_PER_SESSION} files",
)
size_result = await db.execute(
select(func.sum(FileUpload.size_bytes)).where(
FileUpload.session_id == parsed_session_id
)
)
session_bytes = size_result.scalar() or 0
if session_bytes + size_bytes > storage_service.MAX_BYTES_PER_SESSION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session has exceeded the maximum total upload size (50 MB)",
)
# Upload to S3
storage_key = await storage_service.upload_file(
file_data=file_data,
filename=file.filename or "upload",
content_type=content_type,
account_id=str(current_user.account_id),
)
# Persist metadata
upload = FileUpload(
account_id=current_user.account_id,
uploaded_by=current_user.id,
session_id=parsed_session_id,
filename=file.filename or "upload",
content_type=content_type,
size_bytes=size_bytes,
storage_key=storage_key,
)
db.add(upload)
await db.commit()
await db.refresh(upload)
presigned_url = storage_service.get_presigned_url(upload.storage_key)
return FileUploadResponse(
id=upload.id,
filename=upload.filename,
content_type=upload.content_type,
size_bytes=upload.size_bytes,
url=presigned_url,
created_at=upload.created_at,
)
@router.get("/{upload_id}/url")
async def get_upload_url(
upload_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> dict:
"""Get a presigned download URL for an uploaded file."""
_check_storage_configured()
result = await db.execute(select(FileUpload).where(FileUpload.id == upload_id))
upload = result.scalar_one_or_none()
if upload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Verify the upload belongs to the user's account
if upload.account_id != current_user.account_id and not current_user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
url = storage_service.get_presigned_url(upload.storage_key)
return {"url": url}
@router.get("", response_model=list[FileUploadResponse])
async def list_uploads(
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[FileUploadResponse]:
"""List uploads for a session."""
_check_storage_configured()
result = await db.execute(
select(FileUpload).where(
FileUpload.session_id == session_id,
FileUpload.account_id == current_user.account_id,
)
)
uploads = result.scalars().all()
responses = []
for upload in uploads:
presigned_url = storage_service.get_presigned_url(upload.storage_key)
responses.append(
FileUploadResponse(
id=upload.id,
filename=upload.filename,
content_type=upload.content_type,
size_bytes=upload.size_bytes,
url=presigned_url,
created_at=upload.created_at,
)
)
return responses
@router.delete("/{upload_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_upload(
upload_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Delete an uploaded file."""
_check_storage_configured()
result = await db.execute(select(FileUpload).where(FileUpload.id == upload_id))
upload = result.scalar_one_or_none()
if upload is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload not found")
# Verify ownership
if upload.uploaded_by != current_user.id and not current_user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Delete from S3
await storage_service.delete_file(upload.storage_key)
# Delete DB record
await db.delete(upload)
await db.commit()

View File

@@ -21,6 +21,13 @@ from app.api.endpoints import integrations
from app.api.endpoints import onboarding from app.api.endpoints import onboarding
from app.api.endpoints import branding from app.api.endpoints import branding
from app.api.endpoints import supporting_data from app.api.endpoints import supporting_data
from app.api.endpoints import ai_sessions
from app.api.endpoints import flow_proposals
from app.api.endpoints import flowpilot_analytics
from app.api.endpoints import notifications
from app.api.endpoints import public_templates
from app.api.endpoints import admin_gallery
from app.api.endpoints import uploads
api_router = APIRouter() api_router = APIRouter()
@@ -67,3 +74,10 @@ api_router.include_router(integrations.router)
api_router.include_router(onboarding.router) api_router.include_router(onboarding.router)
api_router.include_router(branding.router) api_router.include_router(branding.router)
api_router.include_router(supporting_data.router) api_router.include_router(supporting_data.router)
api_router.include_router(ai_sessions.router)
api_router.include_router(flow_proposals.router)
api_router.include_router(flowpilot_analytics.router)
api_router.include_router(notifications.router)
api_router.include_router(public_templates.router)
api_router.include_router(admin_gallery.router)
api_router.include_router(uploads.router)

View File

@@ -275,13 +275,7 @@ def _build_system_prompt(flow_type: str) -> str:
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}" return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
def _strip_markdown_fences(text: str) -> str: from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences
"""Strip markdown code fences if the model wrapped its JSON response."""
text = text.strip()
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
if match:
return match.group(1).strip()
return text
def _parse_delta(response: str) -> dict | None: def _parse_delta(response: str) -> dict | None:

View File

@@ -86,11 +86,7 @@ def _serialize_tree_outline(
return "\n".join(lines) return "\n".join(lines)
def _strip_markdown_fences(text: str) -> str: from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences
"""Strip ```json...``` fences from AI response."""
return re.sub(r"^```(?:json)?\s*\n?", "", text.strip(), flags=re.MULTILINE).rstrip(
"`"
).strip()
def _replace_node_in_tree( def _replace_node_in_tree(

View File

@@ -13,6 +13,8 @@ import re
import uuid import uuid
from typing import Any from typing import Any
from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences
from app.core.ai_provider import get_ai_provider from app.core.ai_provider import get_ai_provider
from app.core.config import settings from app.core.config import settings
from app.core.ai_tree_validator import validate_generated_tree, count_tree_stats from app.core.ai_tree_validator import validate_generated_tree, count_tree_stats
@@ -111,14 +113,6 @@ Return a corrected full JSON object only. No markdown, no prose, no code fences.
Fix ALL listed errors while maintaining the same troubleshooting/procedural logic.""" Fix ALL listed errors while maintaining the same troubleshooting/procedural logic."""
def _strip_markdown_fences(text: str) -> str:
"""Strip markdown code fences if the model wrapped its JSON response."""
text = text.strip()
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
if match:
return match.group(1).strip()
return text
def _estimate_cost(input_tokens: int, output_tokens: int) -> float: def _estimate_cost(input_tokens: int, output_tokens: int) -> float:

View File

@@ -124,6 +124,13 @@ class Settings(BaseSettings):
"""Check if any AI provider is configured.""" """Check if any AI provider is configured."""
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
# 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"
# ConnectWise PSA Integration # ConnectWise PSA Integration
# CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com # CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com
# All MSP customers share this single clientId — it identifies ResolutionFlow as the integration # All MSP customers share this single clientId — it identifies ResolutionFlow as the integration

View File

@@ -27,6 +27,9 @@ async def get_db() -> AsyncSession:
async with async_session_maker() as session: async with async_session_maker() as session:
try: try:
yield session yield session
except Exception:
await session.rollback()
raise
finally: finally:
await session.close() await session.close()

View File

@@ -484,6 +484,45 @@ class EmailService:
logger.exception("Failed to send beta signup notification for %s", signup_email) logger.exception("Failed to send beta signup notification for %s", signup_email)
return False return False
@staticmethod
async def send_notification_email(
to_email: str,
title: str,
body: str,
link_url: str | None = None,
) -> bool:
"""Send a notification email. Fire-and-forget."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = f"[ResolutionFlow] {title}"
html = _render_notification_html(
title=title,
body=body,
link_url=link_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Notification email sent to %s: %s", to_email, title)
return True
except Exception:
logger.exception("Failed to send notification email to %s", to_email)
return False
@staticmethod @staticmethod
async def send_survey_invite_email( async def send_survey_invite_email(
to_email: str, to_email: str,
@@ -856,3 +895,49 @@ def _render_feedback_confirmation_html(
</td></tr> </td></tr>
</table> </table>
</body></html>""" </body></html>"""
def _render_notification_html(
title: str,
body: str,
link_url: str | None = None,
) -> str:
import html as html_mod
safe_title = html_mod.escape(title)
safe_body = html_mod.escape(body)
link_section = ""
if link_url:
link_section = f"""
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{link_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;">
View in ResolutionFlow
</a>
</td></tr>"""
return 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>
</td></tr>
<tr><td style="padding:0 40px 12px;">
<h2 style="margin:0;color:#f8fafc;font-size:18px;font-weight:600;">{safe_title}</h2>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">{safe_body}</p>
</td></tr>
{link_section}
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
&mdash; ResolutionFlow
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""

View File

@@ -24,13 +24,7 @@ COST_PER_INPUT_TOKEN = 3.0 / 1_000_000
COST_PER_OUTPUT_TOKEN = 15.0 / 1_000_000 COST_PER_OUTPUT_TOKEN = 15.0 / 1_000_000
def _strip_markdown_fences(text: str) -> str: from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences
"""Strip markdown code fences if the model wrapped its JSON response."""
text = text.strip()
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
if match:
return match.group(1).strip()
return text
def _try_repair_json(text: str) -> dict | None: def _try_repair_json(text: str) -> dict | None:

View File

@@ -85,8 +85,21 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
exc_info=True exc_info=True
) )
# Re-raise exception to be handled by FastAPI # Return a proper response so it flows through CORSMiddleware.
raise # Re-raising from BaseHTTPMiddleware bypasses CORS headers.
from starlette.responses import JSONResponse
from fastapi import HTTPException
if isinstance(exc, HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers={"X-Correlation-ID": correlation_id, "X-Process-Time": f"{process_time:.3f}"},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
headers={"X-Correlation-ID": correlation_id, "X-Process-Time": f"{process_time:.3f}"},
)
class ErrorLoggingMiddleware(BaseHTTPMiddleware): class ErrorLoggingMiddleware(BaseHTTPMiddleware):
@@ -95,6 +108,11 @@ class ErrorLoggingMiddleware(BaseHTTPMiddleware):
Ensures all exceptions are logged before being returned to the client, Ensures all exceptions are logged before being returned to the client,
providing full stack traces for debugging. providing full stack traces for debugging.
IMPORTANT: Returns a JSONResponse instead of re-raising so the response
flows back through CORSMiddleware and gets proper CORS headers. Re-raising
exceptions from BaseHTTPMiddleware bypasses CORS, causing browsers to
report CORS errors instead of the actual error (e.g., 401).
""" """
async def dispatch( async def dispatch(
@@ -114,5 +132,17 @@ class ErrorLoggingMiddleware(BaseHTTPMiddleware):
exc_info=True exc_info=True
) )
# Re-raise to let FastAPI handle the response # Return a proper response so it flows through CORSMiddleware.
raise # If we re-raise, the response never passes through CORS and
# browsers see a CORS error instead of the actual error.
from starlette.responses import JSONResponse
from fastapi import HTTPException
if isinstance(exc, HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)

View File

@@ -33,6 +33,7 @@ from app.core.rate_limit import limiter
from app.api.router import api_router from app.api.router import api_router
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
from app.services.retention_cleanup import cleanup_expired_chats from app.services.retention_cleanup import cleanup_expired_chats
from app.services.notification_service import retry_failed_notifications
from app.core.service_account import ensure_service_account from app.core.service_account import ensure_service_account
# Initialize logging configuration # Initialize logging configuration
@@ -61,6 +62,14 @@ async def archive_stale_ai_sessions():
logger.info(f"[archive] Archived {result.rowcount} stale AI chat sessions") logger.info(f"[archive] Archived {result.rowcount} stale AI chat sessions")
async def _process_notification_retries():
"""Retry failed notification deliveries."""
async with async_session_maker() as db:
retried = await retry_failed_notifications(db)
if retried:
logger.info("Retried %d failed notifications", retried)
def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None: def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None:
"""Set globals on a seed script module.""" """Set globals on a seed script module."""
mod.API_BASE_URL = api_url # type: ignore[attr-defined] mod.API_BASE_URL = api_url # type: ignore[attr-defined]
@@ -180,6 +189,37 @@ async def lifespan(app: FastAPI):
replace_existing=True, replace_existing=True,
) )
# PSA push retry (every 5 minutes)
from app.services.psa_retry_scheduler import process_pending_retries
scheduler.add_job(
process_pending_retries,
trigger="interval",
minutes=5,
id="psa_push_retry",
replace_existing=True,
)
# Knowledge Flywheel analysis (every 5 minutes)
from app.services.knowledge_flywheel_scheduler import process_pending_analyses
scheduler.add_job(
process_pending_analyses,
trigger="interval",
minutes=5,
id="knowledge_flywheel_analysis",
replace_existing=True,
max_instances=1,
)
# Notification retry (every minute)
scheduler.add_job(
_process_notification_retries,
trigger="interval",
minutes=1,
id="notification_retry",
replace_existing=True,
max_instances=1,
)
# Auto-seed trees in background on PR environments # Auto-seed trees in background on PR environments
seed_task = None seed_task = None
if settings.SEED_ON_DEPLOY: if settings.SEED_ON_DEPLOY:

View File

@@ -36,10 +36,19 @@ from .survey_response import SurveyResponse
from .survey_invite import SurveyInvite from .survey_invite import SurveyInvite
from .kb_import import KBImport, KBImportNode from .kb_import import KBImport, KBImportNode
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from .ai_session import AISession
from .ai_session_step import AISessionStep
from .psa_connection import PsaConnection from .psa_connection import PsaConnection
from .psa_post_log import PsaPostLog from .psa_post_log import PsaPostLog
from .psa_member_mapping import PsaMemberMapping from .psa_member_mapping import PsaMemberMapping
from .supporting_data import SessionSupportingData from .supporting_data import SessionSupportingData
from .flow_proposal import FlowProposal
from .notification_config import NotificationConfig
from .notification_log import NotificationLog
from .notification import Notification
from .psa_activity_log import PsaActivityLog
from .file_upload import FileUpload
from .ai_session_embedding import AISessionEmbedding
__all__ = [ __all__ = [
"User", "User",
@@ -90,8 +99,17 @@ __all__ = [
"ScriptCategory", "ScriptCategory",
"ScriptTemplate", "ScriptTemplate",
"ScriptGeneration", "ScriptGeneration",
"AISession",
"AISessionStep",
"PsaConnection", "PsaConnection",
"PsaPostLog", "PsaPostLog",
"PsaMemberMapping", "PsaMemberMapping",
"SessionSupportingData", "SessionSupportingData",
"FlowProposal",
"NotificationConfig",
"NotificationLog",
"Notification",
"PsaActivityLog",
"FileUpload",
"AISessionEmbedding",
] ]

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base from app.core.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -44,6 +44,16 @@ class Account(Base):
Integer, nullable=True, default=100, server_default="100" Integer, nullable=True, default=100, server_default="100"
) )
# Custom branding (Task 9)
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4
branding_company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
# SSO / SAML groundwork (Task 11)
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
# Relationships # Relationships
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account") owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account") users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")

View File

@@ -0,0 +1,210 @@
"""AI-powered troubleshooting session model.
Represents a complete FlowPilot interaction from intake to resolution/escalation.
This is the central entity of the FlowPilot-First pivot.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
from app.models.tree import Tree
from app.models.psa_connection import PsaConnection
class AISession(Base):
"""A FlowPilot-guided troubleshooting session.
Lifecycle: active → resolved | escalated | abandoned
Sessions may be paused and resumed (e.g., escalation handoff).
"""
__tablename__ = "ai_sessions"
__table_args__ = (
CheckConstraint(
"intake_type IN ('free_text', 'psa_ticket', 'screenshot', 'log_paste', 'combined')",
name="ck_ai_sessions_intake_type",
),
CheckConstraint(
"status IN ('active', 'paused', 'resolved', 'escalated', 'requesting_escalation', 'abandoned')",
name="ck_ai_sessions_status",
),
CheckConstraint(
"confidence_tier IN ('guided', 'exploring', 'discovery')",
name="ck_ai_sessions_confidence_tier",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Intake ──
intake_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="free_text"
)
intake_content: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict,
comment="Original intake data: {text, image_urls, log_content, ticket_data}",
)
problem_summary: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="AI-generated one-line problem summary from intake",
)
problem_domain: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
comment="Classified domain: active_directory, networking, m365, hardware, etc.",
)
# ── Session state ──
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="active", index=True,
)
confidence_tier: Mapped[str] = mapped_column(
String(20), nullable=False, default="discovery",
comment="Current AI confidence: guided (>80%), exploring (40-80%), discovery (<40%)",
)
confidence_score: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="Numeric confidence 0.0-1.0 for internal tracking",
)
# ── Flow matching ──
matched_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
comment="If following an existing flow, which one",
)
match_score: Mapped[Optional[float]] = mapped_column(
Float, nullable=True,
comment="Similarity score of the matched flow (0.0-1.0)",
)
# ── PSA link ──
psa_ticket_id: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
comment="External PSA ticket ID if session was started from a ticket",
)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("psa_connections.id", ondelete="SET NULL"),
nullable=True,
)
ticket_data: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Snapshot of PSA ticket data at session start",
)
# ── Resolution / Escalation ──
resolution_summary: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="What fixed the issue (set on resolution)",
)
resolution_action: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="The specific action/step that resolved the issue",
)
escalation_reason: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Why escalated (set on escalation)",
)
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
)
escalated_to_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
# ── Feedback ──
session_rating: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
comment="1-5 engineer feedback rating",
)
session_feedback: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Optional feedback text from engineer",
)
# ── Knowledge Flywheel ──
analysis_status: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True,
comment="Knowledge Flywheel status: null (N/A), pending, completed, failed",
)
# ── AI tracking ──
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
step_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
# ── Timestamps ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
resolved_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)
# ── LLM conversation context ──
system_prompt_snapshot: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Snapshot of the system prompt used (for debugging/training)",
)
conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list,
comment="Full LLM message history for context continuity",
)
# ── Relationships ──
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
account: Mapped["Account"] = relationship("Account")
team: Mapped[Optional["Team"]] = relationship("Team")
matched_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[matched_flow_id])
escalated_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[escalated_to_id])
psa_connection: Mapped[Optional["PsaConnection"]] = relationship("PsaConnection")
steps: Mapped[list["AISessionStep"]] = relationship(
"AISessionStep", back_populates="session",
cascade="all, delete-orphan",
order_by="AISessionStep.step_order",
)

View File

@@ -0,0 +1,53 @@
"""AI session embedding storage for similar-session matching.
Stores vector embeddings of AI session content (problem summary, resolution,
domain) for cosine similarity search via pgvector. One embedding per session.
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
try:
from pgvector.sqlalchemy import Vector
except ImportError:
Vector = None
class AISessionEmbedding(Base):
__tablename__ = "ai_session_embeddings"
__table_args__ = (
Index("ix_ai_session_embeddings_account_id", "account_id"),
Index("ix_ai_session_embeddings_session_id", "session_id", unique=True),
)
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,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
)
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
embedding_model: Mapped[str] = mapped_column(
String(50), nullable=False, default="voyage-3.5"
)
# The embedding column is created via migration with vector(1024) type
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,133 @@
"""AI session step model.
Every interaction within an AI session is captured as a step.
Steps are the raw material that becomes flow nodes in the Knowledge Flywheel.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.ai_session import AISession
from app.models.script_template import ScriptGeneration
class AISessionStep(Base):
"""A single interaction step within a FlowPilot session.
Step types:
- question: FlowPilot asks a diagnostic question with options
- action: FlowPilot suggests an action for the engineer to perform
- script_generation: FlowPilot invokes the Script Generator
- verification: FlowPilot asks engineer to verify a condition
- info_request: FlowPilot asks engineer to gather specific data
- note: Engineer or FlowPilot adds a contextual note
- intake_analysis: Initial analysis of the intake content
"""
__tablename__ = "ai_session_steps"
__table_args__ = (
CheckConstraint(
"step_type IN ('question', 'action', 'script_generation', 'verification', "
"'info_request', 'note', 'intake_analysis')",
name="ck_ai_session_steps_step_type",
),
)
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,
index=True,
)
step_order: Mapped[int] = mapped_column(
Integer, nullable=False,
comment="Sequential position in the session (0-indexed)",
)
step_type: Mapped[str] = mapped_column(
String(30), nullable=False,
)
# ── Content presented to engineer ──
content: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict,
comment="The question/action content rendered in the session UI",
)
context_message: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Why FlowPilot is asking this (shown above the question)",
)
# ── Options (for question steps) ──
options_presented: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
JSONB, nullable=True,
comment="Array of {label, value, followup_hint} options shown to engineer",
)
# ── Engineer response ──
selected_option: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True,
comment="Which option the engineer selected (value field)",
)
free_text_input: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="If engineer typed a custom response instead of selecting an option",
)
was_free_text: Mapped[bool] = mapped_column(
default=False,
comment="True if the engineer used the free-text escape hatch",
)
was_skipped: Mapped[bool] = mapped_column(
default=False,
comment="True if engineer selected 'I don't know / Can't check'",
)
# ── Action results ──
action_result: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="Outcome of action step: {success: bool, details: str, next_hint: str}",
)
# ── Script generation link ──
script_generation_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("script_generations.id", ondelete="SET NULL"),
nullable=True,
)
# ── AI internals ──
confidence_at_step: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="FlowPilot confidence level at this point (0.0-1.0)",
)
ai_reasoning: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Why FlowPilot chose this step (internal, for debugging/training)",
)
input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
)
# ── Timestamps ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
responded_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="When the engineer responded to this step",
)
# ── Relationships ──
session: Mapped["AISession"] = relationship("AISession", back_populates="steps")
script_generation: Mapped[Optional["ScriptGeneration"]] = relationship("ScriptGeneration")

View File

@@ -0,0 +1,32 @@
"""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), 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)
)

View File

@@ -0,0 +1,152 @@
"""Flow proposal model.
Generated by the Knowledge Flywheel after AI sessions resolve.
Represents a proposed new flow or enhancement awaiting human review.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
from app.models.tree import Tree
from app.models.ai_session import AISession
class FlowProposal(Base):
"""A proposed new flow or enhancement generated from an AI session.
proposal_type:
- new_flow: No similar flow exists. Full flow definition proposed.
- enhancement: Similar flow exists but session discovered new branch/edge case.
- branch_addition: A single new branch to add to an existing flow.
- auto_reinforced: Session matched existing flow exactly (tracking only).
status:
- pending: Awaiting review
- approved: Reviewed and published to knowledge base
- modified: Reviewer edited before publishing
- rejected: Reviewer decided not to publish (bad quality)
- dismissed: Parked for later — not wrong, just not actionable now.
- auto_reinforced: Session matched existing flow exactly (no review needed)
"""
__tablename__ = "flow_proposals"
__table_args__ = (
CheckConstraint(
"proposal_type IN ('new_flow', 'enhancement', 'branch_addition', 'auto_reinforced')",
name="ck_flow_proposals_type",
),
CheckConstraint(
"status IN ('pending', 'approved', 'modified', 'rejected', 'dismissed', 'auto_reinforced')",
name="ck_flow_proposals_status",
),
)
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,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
source_session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# ── Proposal details ──
proposal_type: Mapped[str] = mapped_column(
String(30), nullable=False,
)
target_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
comment="For enhancements: which existing flow to modify",
)
title: Mapped[str] = mapped_column(
String(255), nullable=False,
comment="Human-readable title for the proposed flow",
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="AI-generated description of what this flow covers",
)
proposed_flow_data: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False,
comment="Complete flow/tree_structure definition (nodes, edges, conditions)",
)
proposed_diff: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="For enhancements: what changed vs existing flow",
)
# ── Scoring ──
confidence_score: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="How confident the system is in this proposal (0.0-1.0)",
)
supporting_session_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=1,
comment="Number of sessions with similar resolution paths",
)
supporting_session_ids: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list,
comment="Array of session IDs that support this proposal",
)
problem_domain: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
)
# ── Review ──
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="pending", index=True,
)
reviewed_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewer_notes: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
)
published_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
comment="The flow that was created/updated when this proposal was approved",
)
# ── Timestamps ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
reviewed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)
# ── Relationships ──
account: Mapped["Account"] = relationship("Account")
team: Mapped[Optional["Team"]] = relationship("Team")
source_session: Mapped["AISession"] = relationship("AISession")
target_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[target_flow_id])
published_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[published_flow_id])
reviewer: Mapped[Optional["User"]] = relationship("User")

View File

@@ -0,0 +1,45 @@
"""In-app notification model."""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class Notification(Base):
__tablename__ = "notifications"
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,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
link: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
index=True,
)
user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,60 @@
"""Notification channel configuration per account.
Each account can have multiple notification configs (email, Slack webhook, Teams webhook).
Each config specifies which events it receives.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
class NotificationConfig(Base):
__tablename__ = "notification_configs"
__table_args__ = (
CheckConstraint(
"channel IN ('email', 'slack_webhook', 'teams_webhook')",
name="ck_notification_configs_channel",
),
)
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,
)
channel: Mapped[str] = mapped_column(String(20), nullable=False)
webhook_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
email_addresses: Mapped[Optional[list]] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
events_enabled: Mapped[dict[str, Any]] = mapped_column(
JSONB, default=lambda: {
"session.escalated": True,
"session.high_priority": True,
"proposal.pending": True,
"proposal.approved": True,
"knowledge_gap.detected": True,
}
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id])

View File

@@ -0,0 +1,52 @@
"""Notification delivery log with retry tracking."""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Integer, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.notification_config import NotificationConfig
class NotificationLog(Base):
__tablename__ = "notification_logs"
__table_args__ = (
CheckConstraint(
"status IN ('sent', 'failed', 'retrying', 'exhausted')",
name="ck_notification_logs_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
notification_config_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("notification_configs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event: Mapped[str] = mapped_column(String(50), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="sent")
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
last_error: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
next_retry_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
delivered_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
config: Mapped[Optional["NotificationConfig"]] = relationship(
"NotificationConfig", foreign_keys=[notification_config_id]
)

View File

@@ -0,0 +1,28 @@
"""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)
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)
)

View File

@@ -1,11 +1,11 @@
"""PSA connection model — one per account.""" """PSA connection model — one per account."""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional, Any
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base from app.core.database import Base
@@ -43,6 +43,10 @@ class PsaConnection(Base):
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),
) )
flowpilot_settings: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True, server_default="{}",
comment="FlowPilot-specific settings: auto_push, time_rounding, note_visibility, etc.",
)
# Relationships # Relationships
account = relationship("Account", back_populates="psa_connection") account = relationship("Account", back_populates="psa_connection")

View File

@@ -3,7 +3,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import String, DateTime, Text, ForeignKey from sqlalchemy import String, DateTime, Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
@@ -16,10 +16,18 @@ class PsaPostLog(Base):
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
) )
session_id: Mapped[uuid.UUID] = mapped_column( # Legacy sessions FK (nullable for AI sessions)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"), ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False, nullable=True,
index=True,
)
# AI sessions FK (Phase 2)
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=True,
index=True, index=True,
) )
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column( psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
@@ -35,8 +43,16 @@ class PsaPostLog(Base):
) )
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(20), nullable=False String(20), nullable=False
) # 'success' or 'failed' ) # 'success', 'failed', 'pending_retry'
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Number of retry attempts for failed PSA pushes",
)
next_retry_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="When to attempt the next retry",
)
status_changed_from: Mapped[Optional[str]] = mapped_column( status_changed_from: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True String(100), nullable=True
) )
@@ -54,5 +70,6 @@ class PsaPostLog(Base):
# Relationships # Relationships
session = relationship("Session", foreign_keys=[session_id]) session = relationship("Session", foreign_keys=[session_id])
ai_session = relationship("AISession", foreign_keys=[ai_session_id])
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id]) psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
user = relationship("User", foreign_keys=[posted_by]) user = relationship("User", foreign_keys=[posted_by])

View File

@@ -65,6 +65,8 @@ class ScriptTemplate(Base):
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_gallery_featured: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
gallery_sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
@@ -97,6 +99,10 @@ class ScriptGeneration(Base):
session_id: Mapped[Optional[uuid.UUID]] = mapped_column( session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True, index=True UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True, index=True
) )
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True, index=True,
comment="FlowPilot AI session that triggered this generation",
)
parameters_used: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) parameters_used: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
generated_script: Mapped[str] = mapped_column(Text, nullable=False) generated_script: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Index, CheckConstraint from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, Index, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base from app.core.database import Base
@@ -85,6 +85,8 @@ class Tree(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_gallery_featured: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
gallery_sort_order: Mapped[int] = mapped_column(Integer, default=0)
visibility: Mapped[str] = mapped_column( visibility: Mapped[str] = mapped_column(
String(20), String(20),
nullable=False, nullable=False,
@@ -161,6 +163,25 @@ class Tree(Base):
comment="Provenance metadata from .rfflow file import" comment="Provenance metadata from .rfflow file import"
) )
# Flow matching (FlowPilot AI sessions)
origin: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True,
comment="manual | ai_generated | ai_enhanced"
)
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), nullable=True,
)
match_keywords: Mapped[Optional[list[Any]]] = mapped_column(
JSONB, nullable=True,
comment="Keywords for FlowPilot flow matching"
)
success_rate: Mapped[Optional[float]] = mapped_column(
Float, nullable=True,
)
last_matched_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)
# Relationships # Relationships
author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees") author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees")
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")

View File

@@ -0,0 +1,203 @@
"""Pydantic schemas for FlowPilot AI sessions."""
from __future__ import annotations
from typing import Optional, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
# ── Intake ──
class AISessionCreateRequest(BaseModel):
"""Start a new FlowPilot session."""
intake_type: str = Field(
"free_text",
pattern="^(free_text|psa_ticket|screenshot|log_paste|combined)$",
)
intake_content: dict[str, Any] = Field(
...,
description=(
"Intake payload. Shape depends on intake_type: "
"{text: str} for free_text, "
"{text?: str, image_urls?: list[str]} for screenshot, "
"{text?: str, log_content?: str} for log_paste, "
"{ticket_id: str, psa_connection_id: str} for psa_ticket, "
"any combination for combined."
),
)
psa_ticket_id: Optional[str] = None
psa_connection_id: Optional[UUID] = None
class AISessionCreateResponse(BaseModel):
"""Response after starting a session — includes the first FlowPilot step."""
session_id: UUID
status: str
confidence_tier: str
problem_summary: str | None = None
problem_domain: str | None = None
matched_flow_id: UUID | None = None
matched_flow_name: str | None = None
match_score: float | None = None
first_step: AISessionStepResponse
psa_context_status: str | None = None # loaded | unavailable | None (no PSA)
# ── Step interaction ──
class StepOptionSchema(BaseModel):
"""A selectable option presented to the engineer."""
label: str
value: str
followup_hint: str | None = None
class AISessionStepResponse(BaseModel):
"""A FlowPilot step rendered in the session UI."""
step_id: UUID
step_order: int
step_type: str
content: dict[str, Any]
context_message: str | None = None
options: list[StepOptionSchema] = []
allow_free_text: bool = True
allow_skip: bool = True
confidence_tier: str
confidence_score: float
model_config = {"from_attributes": True}
class StepResponseRequest(BaseModel):
"""Engineer's response to a FlowPilot step."""
selected_option: str | None = None
free_text_input: str | None = None
was_skipped: bool = False
action_result: dict[str, Any] | None = None
class StepResponseResponse(BaseModel):
"""FlowPilot's next step after processing the engineer's response."""
session_id: UUID
status: str
confidence_tier: str
confidence_score: float
next_step: AISessionStepResponse | None = None
resolution_suggested: bool = False
resolution_summary: str | None = None
# ── Resolution / Escalation ──
class ResolveSessionRequest(BaseModel):
"""Close a session as resolved."""
resolution_summary: str = Field(..., min_length=5, max_length=2000)
resolution_action: str | None = None
session_rating: int | None = Field(None, ge=1, le=5)
session_feedback: str | None = None
class EscalateSessionRequest(BaseModel):
"""Escalate a session to another engineer."""
escalation_reason: str = Field(..., min_length=5, max_length=2000)
escalated_to_id: UUID | None = None
class DocumentationStep(BaseModel):
"""A step in the documentation trail."""
step_number: int
step_type: str
description: str
engineer_response: str | None = None
outcome: str | None = None
class SessionDocumentation(BaseModel):
"""Auto-generated session documentation."""
problem_summary: str
problem_domain: str | None = None
intake_summary: str
diagnostic_steps: list[DocumentationStep]
resolution_summary: str | None = None
escalation_reason: str | None = None
total_steps: int
duration_display: str | None = None
generated_at: datetime
class SessionCloseResponse(BaseModel):
"""Response after resolving or escalating."""
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
class RateSessionRequest(BaseModel):
"""Submit post-session rating."""
rating: int = Field(..., ge=1, le=5)
feedback: str | None = None
class PickupSessionRequest(BaseModel):
"""Pick up an escalated session as a new engineer."""
resume_mode: str = Field("continue", pattern="^(continue|fresh)$")
additional_context: str | None = None
class LinkTicketRequest(BaseModel):
"""Link a PSA ticket to an in-progress session."""
psa_ticket_id: str
psa_connection_id: UUID
# ── List / Detail ──
class AISessionSummary(BaseModel):
"""Compact session for list views."""
id: UUID
status: str
intake_type: str
problem_summary: str | None = None
problem_domain: str | None = None
confidence_tier: str
step_count: int
session_rating: int | None = None
psa_ticket_id: str | None = None
escalation_reason: str | None = None
created_at: datetime
resolved_at: datetime | None = None
model_config = {"from_attributes": True}
class AISessionDetail(AISessionSummary):
"""Full session detail with steps."""
intake_content: dict[str, Any]
matched_flow_id: UUID | None = None
match_score: float | None = None
resolution_summary: str | None = None
resolution_action: str | None = None
escalation_reason: str | None = None
session_feedback: str | None = None
psa_ticket_id: str | None = None
psa_connection_id: UUID | None = None
ticket_data: dict[str, Any] | None = None
steps: list[AISessionStepResponse] = []
model_config = {"from_attributes": True}
class AISessionSearchResult(BaseModel):
"""Lightweight session result for Command Palette / autocomplete."""
id: UUID
problem_summary: str | None = None
problem_domain: str | None = None
status: str
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,51 @@
"""Pydantic schemas for flow proposals (Knowledge Flywheel / Review Queue)."""
from __future__ import annotations
from typing import Optional, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
class FlowProposalSummary(BaseModel):
"""Compact proposal for list views."""
id: UUID
proposal_type: str
title: str
description: str | None = None
problem_domain: str | None = None
confidence_score: float
supporting_session_count: int
status: str
target_flow_id: UUID | None = None
source_session_id: UUID
created_at: datetime
model_config = {"from_attributes": True}
class FlowProposalDetail(FlowProposalSummary):
"""Full proposal detail with flow data."""
proposed_flow_data: dict[str, Any]
proposed_diff: dict[str, Any] | None = None
supporting_session_ids: list[str] = []
reviewer_notes: str | None = None
reviewed_by: UUID | None = None
reviewed_at: datetime | None = None
class ReviewProposalRequest(BaseModel):
"""Review action on a proposal."""
action: str = Field(..., pattern="^(approve|reject|modify|dismiss)$")
reviewer_notes: str | None = None
modified_flow_data: dict[str, Any] | None = None # Only for "modify"
class FlowProposalStats(BaseModel):
"""Dashboard stats for the review queue."""
pending_count: int
approved_this_week: int
rejected_this_week: int
auto_reinforced_this_week: int
top_domains: list[dict[str, Any]] = [] # [{domain, count}]

View File

@@ -0,0 +1,126 @@
"""Pydantic schemas for FlowPilot analytics dashboard."""
from __future__ import annotations
from typing import Optional, Any
from datetime import datetime
from pydantic import BaseModel
class MTTRDataPoint(BaseModel):
date: str
mttr_minutes: float
session_count: int
class DomainBreakdown(BaseModel):
domain: str
total: int
resolved: int
escalated: int
resolution_rate: float
class ConfidenceBreakdown(BaseModel):
guided_sessions: int
guided_resolution_rate: float
exploring_sessions: int
exploring_resolution_rate: float
discovery_sessions: int
discovery_resolution_rate: float
class DomainCoverage(BaseModel):
domain: str
flow_count: int
session_count: int
guided_rate: float
class KnowledgeCoverage(BaseModel):
total_flows: int
ai_generated_flows: int
total_proposals_pending: int
proposals_approved_this_period: int
proposals_rejected_this_period: int
coverage_by_domain: list[DomainCoverage] = []
class PsaMetrics(BaseModel):
ticket_link_rate: float
auto_push_success_rate: float
auto_push_retry_success_rate: float
total_time_entries_logged: int
total_hours_logged: float
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
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]
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]
class FlowPilotDashboard(BaseModel):
period: str
total_sessions: int
resolved_sessions: int
escalated_sessions: int
abandoned_sessions: int
resolution_rate: float
avg_steps_to_resolution: float
avg_session_duration_minutes: float
avg_rating: float | None = None
mttr_minutes: float | None = None
mttr_trend: list[MTTRDataPoint] = []
sessions_by_domain: list[DomainBreakdown] = []
confidence_breakdown: ConfidenceBreakdown
knowledge_coverage: KnowledgeCoverage
psa_metrics: PsaMetrics | None = None

View File

@@ -0,0 +1,85 @@
"""Pydantic schemas for notification system."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field, field_validator
VALID_EVENTS = {
"session.escalated",
"session.high_priority",
"proposal.pending",
"proposal.approved",
"knowledge_gap.detected",
}
class NotificationConfigCreate(BaseModel):
channel: str = Field(..., pattern="^(email|slack_webhook|teams_webhook)$")
webhook_url: str | None = None
email_addresses: list[str] | None = None
events_enabled: dict[str, bool] = Field(
default_factory=lambda: {e: True for e in VALID_EVENTS}
)
@field_validator("events_enabled")
@classmethod
def validate_event_keys(cls, v: dict[str, bool]) -> dict[str, bool]:
invalid = set(v) - VALID_EVENTS
if invalid:
raise ValueError(f"Unknown event keys: {invalid}")
return v
class NotificationConfigUpdate(BaseModel):
webhook_url: str | None = None
email_addresses: list[str] | None = None
is_active: bool | None = None
events_enabled: dict[str, bool] | None = None
@field_validator("events_enabled")
@classmethod
def validate_event_keys(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
if v is not None:
invalid = set(v) - VALID_EVENTS
if invalid:
raise ValueError(f"Unknown event keys: {invalid}")
return v
class NotificationConfigResponse(BaseModel):
id: UUID
channel: str
webhook_url: str | None
email_addresses: list[str] | None
is_active: bool
events_enabled: dict[str, bool]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class NotificationResponse(BaseModel):
id: UUID
event: str
title: str
body: str | None
link: str | None
is_read: bool
created_at: datetime
model_config = {"from_attributes": True}
class UnreadCountResponse(BaseModel):
count: int
class NotificationTestRequest(BaseModel):
config_id: UUID
class NotificationTestResponse(BaseModel):
success: bool
message: str

View File

@@ -0,0 +1,83 @@
"""Schemas for the public templates gallery. No auth required."""
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel
class PublicFlowTemplate(BaseModel):
id: UUID
name: str
description: str | None = None
category: str | None = None
tree_type: str
step_count: int
usage_count: int
success_rate: float | None = None
tags: list[str] = []
preview_structure: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class PublicScriptTemplate(BaseModel):
id: UUID
name: str
description: str | None = None
category_name: str | None = None
category_icon: str | None = None
complexity: str | None = None
tags: list[str] = []
parameter_count: int = 0
requires_elevation: bool = False
requires_modules: list[str] = []
usage_count: int = 0
is_verified: bool = False
created_at: datetime
model_config = {"from_attributes": True}
class PublicGalleryResponse(BaseModel):
flow_templates: list[PublicFlowTemplate]
script_templates: list[PublicScriptTemplate]
total_flows: int
total_scripts: int
categories: list[str]
domains: list[str]
class PublicFlowDetail(BaseModel):
id: UUID
name: str
description: str | None = None
category: str | None = None
tree_type: str
step_count: int
usage_count: int
success_rate: float | None = None
tags: list[str] = []
preview_structure: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class PublicScriptDetail(BaseModel):
id: UUID
name: str
description: str | None = None
category_name: str | None = None
complexity: str | None = None
tags: list[str] = []
parameters: list[dict[str, Any]] = []
requires_elevation: bool = False
requires_modules: list[str] = []
usage_count: int = 0
is_verified: bool = False
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -116,7 +116,8 @@ class ScriptTemplateDetail(ScriptTemplateListItem):
class ScriptGenerateRequest(BaseModel): class ScriptGenerateRequest(BaseModel):
template_id: UUID template_id: UUID
parameters: dict[str, Any] parameters: dict[str, Any]
session_id: Optional[UUID] = None session_id: Optional[UUID] = None # Legacy tree-based session
ai_session_id: Optional[UUID] = None # FlowPilot AI session
class ScriptGenerateResponse(BaseModel): class ScriptGenerateResponse(BaseModel):
id: UUID id: UUID

View File

@@ -40,6 +40,7 @@ class SidebarStatsResponse(BaseModel):
resolved_today: int resolved_today: int
active_count: int active_count: int
total_session_minutes_today: int total_session_minutes_today: int
escalation_count: int = 0
tree_counts: SidebarTreeCounts tree_counts: SidebarTreeCounts
active_sessions: list[SidebarActiveSession] active_sessions: list[SidebarActiveSession]
recent_completions: list[SidebarRecentSession] recent_completions: list[SidebarRecentSession]

View File

@@ -0,0 +1,15 @@
"""Schemas for file upload endpoints."""
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
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -171,10 +171,10 @@ def _escape_markdown_table(value: str) -> str:
return value.replace("|", "\\|").replace("\n", " ") return value.replace("|", "\\|").replace("\n", " ")
def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str:
"""Generate markdown export.""" """Generate markdown export."""
if _is_procedural_session(session): if _is_procedural_session(session):
return _generate_procedural_markdown(session, options) return _generate_procedural_markdown(session, options, uploads=uploads)
lines = [] lines = []
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -223,6 +223,21 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin
lines.append("---") lines.append("---")
lines.append("") lines.append("")
# File upload evidence
if uploads:
lines.append("## Evidence")
lines.append("")
for upload in uploads:
name = upload["filename"]
url = upload["url"]
if upload.get("is_image"):
lines.append(f"- ![{name}]({url})")
else:
lines.append(f"- [{name}]({url})")
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Troubleshooting Steps") lines.append("## Troubleshooting Steps")
lines.append("") lines.append("")
@@ -299,13 +314,17 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin
lines.append(next_steps.strip()) lines.append(next_steps.strip())
lines.append("") lines.append("")
# Branding footer
lines.append("---")
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
return "\n".join(lines) return "\n".join(lines)
def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str:
"""Generate plain text export.""" """Generate plain text export."""
if _is_procedural_session(session): if _is_procedural_session(session):
return _generate_procedural_text(session, options) return _generate_procedural_text(session, options, uploads=uploads)
lines = [] lines = []
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -345,6 +364,13 @@ def generate_text_export(session: Session, options: SessionExport, supporting_da
lines.append(scratchpad) lines.append(scratchpad)
lines.append("") lines.append("")
# File upload evidence
if uploads:
lines.append("--- Evidence ---")
for upload in uploads:
lines.append(f"- {upload['filename']}: {upload['url']}")
lines.append("")
lines.append("TROUBLESHOOTING STEPS") lines.append("TROUBLESHOOTING STEPS")
lines.append("-" * 20) lines.append("-" * 20)
@@ -408,13 +434,18 @@ def generate_text_export(session: Session, options: SessionExport, supporting_da
lines.append("-" * 20) lines.append("-" * 20)
lines.append(next_steps.strip()) lines.append(next_steps.strip())
# Branding footer
lines.append("")
lines.append("---")
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
return "\n".join(lines) return "\n".join(lines)
def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str:
"""Generate HTML export.""" """Generate HTML export."""
if _is_procedural_session(session): if _is_procedural_session(session):
return _generate_procedural_html(session, options) return _generate_procedural_html(session, options, uploads=uploads)
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -467,6 +498,19 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da
html_parts.append('<h2>Evidence / Reference</h2>') html_parts.append('<h2>Evidence / Reference</h2>')
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>') html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
# File upload evidence
if uploads:
html_parts.append('<h3>Evidence</h3>')
html_parts.append('<div class="evidence-grid" style="margin-bottom: 20px;">')
for upload in uploads:
name = html.escape(upload["filename"])
url = html.escape(upload["url"])
if upload.get("is_image"):
html_parts.append(f'<img src="{url}" alt="{name}" style="max-width: 400px; border-radius: 8px; display: block; margin-bottom: 8px;" />')
else:
html_parts.append(f'<p><a href="{url}">{name}</a></p>')
html_parts.append('</div>')
html_parts.append('<h2>Troubleshooting Steps</h2>') html_parts.append('<h2>Troubleshooting Steps</h2>')
decisions = session.decisions decisions = session.decisions
@@ -524,14 +568,18 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da
html_parts.append('<h2>Next Steps</h2>') html_parts.append('<h2>Next Steps</h2>')
html_parts.append(f'<div style="white-space: pre-wrap; margin-bottom: 20px;">{html.escape(next_steps.strip())}</div>') html_parts.append(f'<div style="white-space: pre-wrap; margin-bottom: 20px;">{html.escape(next_steps.strip())}</div>')
# Branding footer
html_parts.append('<hr style="margin-top: 32px; border: none; border-top: 1px solid #ddd;">')
html_parts.append('<p style="margin-top: 12px; font-size: 0.8em; color: #999; text-align: center;">Generated with <a href="https://resolutionflow.com" style="color: #06b6d4; text-decoration: none;">ResolutionFlow</a> — https://resolutionflow.com</p>')
html_parts.extend(['</body>', '</html>']) html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts) return "\n".join(html_parts)
def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools.""" """Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
if _is_procedural_session(session): if _is_procedural_session(session):
return _generate_procedural_psa(session, options) return _generate_procedural_psa(session, options, uploads=uploads)
lines = [] lines = []
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -648,6 +696,18 @@ def generate_psa_export(session: Session, options: SessionExport, supporting_dat
scratchpad = getattr(session, 'scratchpad', '') or '' scratchpad = getattr(session, 'scratchpad', '') or ''
lines.append(scratchpad.strip() if scratchpad.strip() else "None") lines.append(scratchpad.strip() if scratchpad.strip() else "None")
# File upload evidence
if uploads:
lines.append("")
lines.append("--- Evidence ---")
for upload in uploads:
lines.append(f"- {upload['filename']} — [{upload['url']}]")
# Branding footer
lines.append("")
lines.append("---")
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
return "\n".join(lines) return "\n".join(lines)
@@ -664,7 +724,7 @@ def _get_session_variables(session: Session) -> dict[str, str]:
return {} return {}
def _generate_procedural_markdown(session: Session, options: SessionExport) -> str: def _generate_procedural_markdown(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str:
"""Generate markdown export for procedural sessions.""" """Generate markdown export for procedural sessions."""
lines = [] lines = []
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -737,10 +797,29 @@ def _generate_procedural_markdown(session: Session, options: SessionExport) -> s
lines.append(outcome_notes.strip()) lines.append(outcome_notes.strip())
lines.append("") lines.append("")
# File upload evidence
if uploads:
lines.append("---")
lines.append("")
lines.append("## Evidence")
lines.append("")
for upload in uploads:
name = upload["filename"]
url = upload["url"]
if upload.get("is_image"):
lines.append(f"- ![{name}]({url})")
else:
lines.append(f"- [{name}]({url})")
lines.append("")
# Branding footer
lines.append("---")
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
return "\n".join(lines) return "\n".join(lines)
def _generate_procedural_text(session: Session, options: SessionExport) -> str: def _generate_procedural_text(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str:
"""Generate plain text export for procedural sessions.""" """Generate plain text export for procedural sessions."""
lines = [] lines = []
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -802,10 +881,22 @@ def _generate_procedural_text(session: Session, options: SessionExport) -> str:
lines.append("-" * 20) lines.append("-" * 20)
lines.append(outcome_notes.strip()) lines.append(outcome_notes.strip())
# File upload evidence
if uploads:
lines.append("")
lines.append("--- Evidence ---")
for upload in uploads:
lines.append(f"- {upload['filename']}: {upload['url']}")
# Branding footer
lines.append("")
lines.append("---")
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
return "\n".join(lines) return "\n".join(lines)
def _generate_procedural_html(session: Session, options: SessionExport) -> str: def _generate_procedural_html(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str:
"""Generate HTML export for procedural sessions.""" """Generate HTML export for procedural sessions."""
tree_name = html.escape(session.tree_snapshot.get("name", "Procedure")) tree_name = html.escape(session.tree_snapshot.get("name", "Procedure"))
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -887,11 +978,28 @@ def _generate_procedural_html(session: Session, options: SessionExport) -> str:
html_parts.append('<h2>Notes</h2>') html_parts.append('<h2>Notes</h2>')
html_parts.append(f'<div style="white-space: pre-wrap;">{html.escape(outcome_notes.strip())}</div>') html_parts.append(f'<div style="white-space: pre-wrap;">{html.escape(outcome_notes.strip())}</div>')
# File upload evidence
if uploads:
html_parts.append('<h3>Evidence</h3>')
html_parts.append('<div class="evidence-grid" style="margin-bottom: 20px;">')
for upload in uploads:
name = html.escape(upload["filename"])
url = html.escape(upload["url"])
if upload.get("is_image"):
html_parts.append(f'<img src="{url}" alt="{name}" style="max-width: 400px; border-radius: 8px; display: block; margin-bottom: 8px;" />')
else:
html_parts.append(f'<p><a href="{url}">{name}</a></p>')
html_parts.append('</div>')
# Branding footer
html_parts.append('<hr style="margin-top: 32px; border: none; border-top: 1px solid #ddd;">')
html_parts.append('<p style="margin-top: 12px; font-size: 0.8em; color: #999; text-align: center;">Generated with <a href="https://resolutionflow.com" style="color: #06b6d4; text-decoration: none;">ResolutionFlow</a> — https://resolutionflow.com</p>')
html_parts.extend(['</body>', '</html>']) html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts) return "\n".join(html_parts)
def _generate_procedural_psa(session: Session, options: SessionExport) -> str: def _generate_procedural_psa(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str:
"""Generate PSA/ticket export for procedural sessions.""" """Generate PSA/ticket export for procedural sessions."""
lines = [] lines = []
outcome_label = _get_outcome_label(session) outcome_label = _get_outcome_label(session)
@@ -956,6 +1064,18 @@ def _generate_procedural_psa(session: Session, options: SessionExport) -> str:
lines.append("--- TIME SPENT ---") lines.append("--- TIME SPENT ---")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
# File upload evidence
if uploads:
lines.append("")
lines.append("--- Evidence ---")
for upload in uploads:
lines.append(f"- {upload['filename']} — [{upload['url']}]")
# Branding footer
lines.append("")
lines.append("---")
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
return "\n".join(lines) return "\n".join(lines)
@@ -1059,6 +1179,32 @@ async def generate_pdf_export(session: Session, options: SessionExport, db) -> b
for sd in supporting_data_rows for sd in supporting_data_rows
] ]
# Query file upload evidence
from app.models.file_upload import FileUpload
from app.services import storage_service
from app.core.config import settings as _settings
uploads_for_export: list[dict] = []
if _settings.STORAGE_ENDPOINT:
try:
uploads_result = await db.execute(
sa_select(FileUpload)
.where(FileUpload.session_id == session.id)
.order_by(FileUpload.created_at)
)
upload_rows = uploads_result.scalars().all()
for u in upload_rows:
try:
url = storage_service.get_presigned_url(u.storage_key)
uploads_for_export.append({
"filename": u.filename,
"url": url,
"is_image": u.content_type.startswith("image/"),
})
except Exception:
pass # Skip individual uploads that fail URL generation
except Exception:
pass # Storage errors should not fail the export
# Calculate duration and format outcome # Calculate duration and format outcome
duration = _format_duration(session.started_at, session.completed_at) duration = _format_duration(session.started_at, session.completed_at)
session_date = session.started_at.strftime("%Y-%m-%d %H:%M") session_date = session.started_at.strftime("%Y-%m-%d %H:%M")
@@ -1136,6 +1282,7 @@ async def generate_pdf_export(session: Session, options: SessionExport, db) -> b
summary=summary_text, summary=summary_text,
steps=steps, steps=steps,
supporting_data=supporting_data, supporting_data=supporting_data,
uploads=uploads_for_export,
generated_at=generated_at, generated_at=generated_at,
) )

View File

@@ -0,0 +1,278 @@
"""Flow Matching Engine v1 — find existing flows relevant to an AI session's intake.
Combines keyword matching, semantic search (via RAG embeddings), and recency
scoring to rank flows. Deliberately simple for v1; v2 (Phase 3) adds deeper
semantic matching.
Scoring weights: semantic 0.5, keyword 0.3, recency 0.2.
Threshold: only return matches with composite score > 0.5.
"""
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tree import Tree
from app.services.rag_service import search as rag_search
logger = logging.getLogger(__name__)
# Scoring weights
SEMANTIC_WEIGHT = 0.5
KEYWORD_WEIGHT = 0.3
RECENCY_WEIGHT = 0.2
# Only return matches above this composite score
SCORE_THRESHOLD = 0.5
async def find_matches(
intake_text: str,
problem_domain: Optional[str],
account_id: UUID,
db: AsyncSession,
limit: int = 5,
) -> list[dict[str, Any]]:
"""Find existing flows that match the intake description.
Returns list of dicts sorted by composite score:
{tree_id, tree_name, score, match_reason}
"""
candidates: dict[str, dict[str, Any]] = {}
# 1. Semantic search via existing RAG embeddings
try:
rag_results = await rag_search(
query=intake_text,
account_id=account_id,
db=db,
limit=10,
)
for r in rag_results:
tree_id = str(r["tree_id"])
similarity = r.get("similarity", 0.0)
if tree_id not in candidates:
candidates[tree_id] = {
"tree_id": tree_id,
"tree_name": r["tree_name"],
"semantic_score": similarity,
"keyword_score": 0.0,
"recency_score": 0.0,
"match_reasons": [],
}
else:
# Take the best semantic score across chunks
candidates[tree_id]["semantic_score"] = max(
candidates[tree_id]["semantic_score"], similarity
)
if similarity > 0.5:
candidates[tree_id]["match_reasons"].append(
f"semantic match ({similarity:.0%})"
)
except Exception as e:
logger.warning("Semantic search failed during flow matching: %s", e)
# 2. Keyword matching against trees.match_keywords
try:
keyword_matches = await _keyword_match(intake_text, account_id, db)
for km in keyword_matches:
tree_id = str(km["tree_id"])
if tree_id not in candidates:
candidates[tree_id] = {
"tree_id": tree_id,
"tree_name": km["tree_name"],
"semantic_score": 0.0,
"keyword_score": km["score"],
"recency_score": 0.0,
"match_reasons": [],
}
else:
candidates[tree_id]["keyword_score"] = km["score"]
if km["score"] > 0.3:
candidates[tree_id]["match_reasons"].append(
f"keyword match: {', '.join(km.get('matched_keywords', []))}"
)
except Exception as e:
logger.warning("Keyword matching failed: %s", e)
# 3. Category/domain match
if problem_domain:
try:
domain_matches = await _domain_match(problem_domain, account_id, db)
for dm in domain_matches:
tree_id = str(dm["tree_id"])
if tree_id not in candidates:
candidates[tree_id] = {
"tree_id": tree_id,
"tree_name": dm["tree_name"],
"semantic_score": 0.0,
"keyword_score": 0.2, # Small boost for domain match
"recency_score": 0.0,
"match_reasons": [],
}
else:
candidates[tree_id]["keyword_score"] = max(
candidates[tree_id]["keyword_score"], 0.2
)
candidates[tree_id]["match_reasons"].append(f"domain match: {problem_domain}")
except Exception as e:
logger.warning("Domain matching failed: %s", e)
# 4. Apply recency boost
now = datetime.now(timezone.utc)
for tree_id, candidate in candidates.items():
# We'll compute recency from the tree data if available
candidate["recency_score"] = 0.0 # Default, updated below
# Fetch recency data for all candidates
if candidates:
try:
recency_data = await _get_recency_scores(
list(candidates.keys()), db
)
for tree_id, recency_score in recency_data.items():
if tree_id in candidates:
candidates[tree_id]["recency_score"] = recency_score
except Exception as e:
logger.warning("Recency scoring failed: %s", e)
# 5. Compute composite scores and filter
results = []
for tree_id, c in candidates.items():
composite = (
c["semantic_score"] * SEMANTIC_WEIGHT
+ c["keyword_score"] * KEYWORD_WEIGHT
+ c["recency_score"] * RECENCY_WEIGHT
)
if composite > SCORE_THRESHOLD:
results.append({
"tree_id": UUID(tree_id),
"tree_name": c["tree_name"],
"score": round(composite, 3),
"match_reason": "; ".join(c["match_reasons"][:3]) if c["match_reasons"] else "composite match",
})
# Sort by score descending, take top N
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
async def _keyword_match(
intake_text: str,
account_id: UUID,
db: AsyncSession,
) -> list[dict[str, Any]]:
"""Match intake text against trees.match_keywords JSONB arrays.
Simple approach: tokenize intake text, check overlap with each tree's keywords.
"""
# Extract meaningful tokens from intake (lowercase, 3+ chars)
tokens = set()
for word in intake_text.lower().split():
cleaned = "".join(c for c in word if c.isalnum())
if len(cleaned) >= 3:
tokens.add(cleaned)
if not tokens:
return []
# Find trees with match_keywords set
result = await db.execute(
select(Tree.id, Tree.name, Tree.match_keywords)
.where(
Tree.account_id == account_id,
Tree.deleted_at.is_(None),
Tree.status == "published",
Tree.match_keywords.isnot(None),
)
)
rows = result.all()
matches = []
for row in rows:
tree_keywords = row.match_keywords or []
if not isinstance(tree_keywords, list):
continue
# Lowercase keywords for comparison
kw_lower = {str(kw).lower() for kw in tree_keywords}
# Calculate overlap
matched = tokens & kw_lower
if matched:
score = len(matched) / max(len(kw_lower), 1)
matches.append({
"tree_id": row.id,
"tree_name": row.name,
"score": min(score, 1.0),
"matched_keywords": list(matched)[:5],
})
return matches
async def _domain_match(
problem_domain: str,
account_id: UUID,
db: AsyncSession,
) -> list[dict[str, Any]]:
"""Find trees whose category matches the classified problem domain."""
result = await db.execute(
select(Tree.id, Tree.name)
.where(
Tree.account_id == account_id,
Tree.deleted_at.is_(None),
Tree.status == "published",
Tree.category.ilike(f"%{problem_domain}%"),
)
.limit(10)
)
rows = result.all()
return [{"tree_id": row.id, "tree_name": row.name} for row in rows]
async def _get_recency_scores(
tree_ids: list[str],
db: AsyncSession,
) -> dict[str, float]:
"""Calculate recency scores based on last_matched_at.
Trees matched within the last 7 days get full recency boost (0.2 → 1.0).
Trees matched within 30 days get partial boost.
Older or never-matched trees get 0.
"""
if not tree_ids:
return {}
result = await db.execute(
select(Tree.id, Tree.last_matched_at, Tree.success_rate)
.where(Tree.id.in_([UUID(tid) for tid in tree_ids]))
)
rows = result.all()
now = datetime.now(timezone.utc)
scores = {}
for row in rows:
tree_id = str(row.id)
if row.last_matched_at is None:
scores[tree_id] = 0.0
continue
days_since = (now - row.last_matched_at).days
if days_since <= 7:
recency = 1.0
elif days_since <= 30:
recency = 1.0 - ((days_since - 7) / 23) # Linear decay 7-30 days
else:
recency = 0.0
# Factor in success rate if available
if row.success_rate is not None:
recency *= row.success_rate
scores[tree_id] = max(0.0, min(1.0, recency))
return scores

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
"""Knowledge Flywheel — post-session analysis engine.
Analyzes resolved AI sessions and generates flow proposals:
- new_flow: Novel resolution path → propose a new troubleshooting flow
- enhancement: Diverged from a matched flow → propose additions
- auto_reinforced: Followed a flow exactly → update flow stats
Called by the knowledge_flywheel_scheduler (APScheduler) after sessions resolve.
"""
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.services.llm_utils import parse_llm_json
from app.services.notification_service import notify
from app.models.ai_session import AISession
from app.models.ai_session_step import AISessionStep
from app.models.flow_proposal import FlowProposal
from app.models.tree import Tree
logger = logging.getLogger(__name__)
# Daily budget cap for proposal generation LLM calls per account
MAX_PROPOSALS_PER_DAY = 50
FLOW_GENERATION_PROMPT = """\
You are a knowledge engineer converting a troubleshooting session into a reusable flow definition.
Given the session transcript below, generate a JSON flow definition that captures the diagnostic logic so other engineers can follow the same path.
## OUTPUT FORMAT
Respond with ONLY valid JSON:
{
"title": "Short descriptive title (5-10 words)",
"description": "When to use this flow (1-2 sentences)",
"match_keywords": ["keyword1", "keyword2", ...],
"problem_domain": "active_directory | networking | m365 | hardware | endpoint | virtualization | security | backup | email | printing | cloud | other",
"tree_structure": {
"id": "root",
"type": "decision",
"question": "First diagnostic question",
"help_text": "Context for the engineer",
"options": [
{"id": "opt1", "label": "Option text", "next_node_id": "node_id"}
],
"children": [
{
"id": "node_id",
"type": "decision | action | solution",
"title": "Node title",
"question": "For decision nodes",
"description": "For action/solution nodes",
"options": [],
"next_node_id": "next_id or null for terminal nodes"
}
]
}
}
## RULES
- tree_structure uses a flat children array with id-based references via next_node_id
- The root node has type "decision" with a question and options
- Decision nodes have options with next_node_id pointing to child nodes
- Action nodes describe what the engineer should do with a description field
- Solution nodes describe the resolution (terminal — no next_node_id)
- Every decision node must have 2-5 options
- Include the key diagnostic questions that narrowed down the problem
- Skip redundant or dead-end paths from the session
- match_keywords should be symptoms, error messages, and technology names (5-10 keywords)
- Do NOT wrap JSON in markdown code fences\
"""
ENHANCEMENT_PROMPT = """\
You are a knowledge engineer analyzing how a troubleshooting session diverged from an existing flow.
Given the session transcript and the existing flow structure, identify what should be added or changed.
## OUTPUT FORMAT
Respond with ONLY valid JSON:
{
"title": "Enhancement: <what changed>",
"description": "Why this enhancement is needed",
"diff_description": "Human-readable summary of changes",
"new_nodes": [
{
"id": "new_node_id",
"type": "decision | action | solution",
"title": "Node title",
"question": "For decision nodes",
"description": "For action/solution nodes",
"options": [],
"attach_after_node_id": "existing node ID where this branches off",
"new_option_label": "Label for the new option on the parent node"
}
],
"modified_options": [
{
"node_id": "existing node ID",
"add_option": {"id": "new_opt", "label": "New option text", "next_node_id": "new_node_id"}
}
]
}
## RULES
- Only propose changes supported by the session evidence
- Minimize changes — add branches, don't restructure
- new_nodes should follow the same format as the existing flow
- Do NOT wrap JSON in markdown code fences\
"""
def _build_session_context(session: AISession) -> str:
"""Build a text summary of a session for the LLM prompt."""
parts = [
f"Problem: {session.problem_summary or 'Unknown'}",
f"Domain: {session.problem_domain or 'Unknown'}",
f"Confidence at resolution: {session.confidence_tier} ({session.confidence_score:.0%})",
f"Resolution: {session.resolution_summary or 'No summary'}",
]
if session.escalation_reason:
parts.append(f"Escalation reason: {session.escalation_reason}")
# Build step-by-step diagnostic trail
steps = sorted(session.steps, key=lambda s: s.step_order)
if steps:
parts.append("\n--- DIAGNOSTIC TRAIL ---")
for step in steps:
content = step.content or {}
step_desc = content.get("text", "")
step_type = content.get("type", step.step_type)
line = f"Step {step.step_order + 1} [{step_type}]: {step_desc}"
# Engineer response
if step.was_skipped:
line += "\n → Skipped"
elif step.selected_option:
# Find label from options
label = step.selected_option
if step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
label = opt.get("label", step.selected_option)
break
line += f"\n → Selected: {label}"
elif step.free_text_input:
line += f"\n → Free text: {step.free_text_input}"
if step.action_result:
result = step.action_result
outcome = "Succeeded" if result.get("success") else "Did not resolve"
if details := result.get("details"):
outcome += f"{details}"
line += f"\n → Result: {outcome}"
parts.append(line)
return "\n".join(parts)
def _has_free_text_escapes(session: AISession) -> bool:
"""Check if the session used free-text escapes (diverged from options)."""
return any(step.was_free_text for step in session.steps)
async def _check_daily_budget(account_id: UUID, db: AsyncSession) -> bool:
"""Check if the account has exceeded the daily proposal generation budget."""
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(FlowProposal.id))
.where(
FlowProposal.account_id == account_id,
FlowProposal.created_at >= today_start,
FlowProposal.status != "auto_reinforced", # Don't count no-LLM proposals
)
)
count = result.scalar() or 0
return count < MAX_PROPOSALS_PER_DAY
async def _find_similar_pending_proposal(
title: str,
problem_domain: Optional[str],
account_id: UUID,
db: AsyncSession,
) -> Optional[FlowProposal]:
"""Find an existing pending proposal with similar title and domain.
Uses simple keyword overlap for now. Phase 4 will add embedding similarity.
"""
# Build domain filter — match NULL domain proposals if domain is NULL
domain_filter = (
FlowProposal.problem_domain == problem_domain
if problem_domain
else FlowProposal.problem_domain.is_(None)
)
result = await db.execute(
select(FlowProposal)
.where(
FlowProposal.account_id == account_id,
FlowProposal.status == "pending",
domain_filter,
)
.limit(20)
)
candidates = result.scalars().all()
if not candidates:
return None
# Simple keyword overlap check
title_words = set(title.lower().split())
for candidate in candidates:
candidate_words = set(candidate.title.lower().split())
if len(title_words) > 0 and len(candidate_words) > 0:
overlap = len(title_words & candidate_words) / max(len(title_words), len(candidate_words))
if overlap > 0.6:
return candidate
return None
async def analyze_session(session: AISession, db: AsyncSession) -> None:
"""Analyze a resolved session and create appropriate flow proposal.
Dispatches to one of three outcomes:
1. new_flow — novel resolution, no matching flow
2. enhancement — matched flow but diverged
3. auto_reinforced — followed existing flow closely
"""
# Re-fetch with eager-loaded steps to avoid async lazy-load errors
result = await db.execute(
select(AISession)
.where(AISession.id == session.id)
.options(selectinload(AISession.steps))
)
session = result.scalar_one()
# Determine which analysis path to take
has_match = session.matched_flow_id is not None
match_score = session.match_score or 0.0
has_divergence = _has_free_text_escapes(session)
if has_match and match_score > 0.8 and not has_divergence:
# Path 3: Auto-reinforcement
await _auto_reinforce(session, db)
elif has_match and match_score > 0.5 and has_divergence:
# Path 2: Enhancement proposal
await _propose_enhancement(session, db)
elif not has_match or match_score < 0.5:
# Path 1: New flow proposal
await _propose_new_flow(session, db)
else:
# Edge case: matched but moderate score, no divergence — reinforce
await _auto_reinforce(session, db)
async def _auto_reinforce(session: AISession, db: AsyncSession) -> None:
"""Update the matched flow's stats and create a tracking record."""
if session.matched_flow_id:
result = await db.execute(
select(Tree).where(Tree.id == session.matched_flow_id)
)
flow = result.scalar_one_or_none()
if flow:
# Update flow stats
current_rate = flow.success_rate or 0.0
# Simple moving average
flow.success_rate = round(current_rate * 0.9 + 1.0 * 0.1, 4)
flow.last_matched_at = datetime.now(timezone.utc)
# Create tracking record (no review needed)
proposal = FlowProposal(
id=uuid.uuid4(),
account_id=session.account_id,
team_id=session.team_id,
source_session_id=session.id,
proposal_type="auto_reinforced",
title=f"Reinforcement: {session.problem_summary or 'Session'}",
description="Session followed existing flow closely. No changes needed.",
proposed_flow_data={},
confidence_score=session.confidence_score,
supporting_session_ids=[str(session.id)],
problem_domain=session.problem_domain,
status="auto_reinforced",
target_flow_id=session.matched_flow_id,
)
db.add(proposal)
# auto_reinforced proposals don't need review — no notification
logger.info("Auto-reinforced flow %s from session %s", session.matched_flow_id, session.id)
async def _propose_new_flow(session: AISession, db: AsyncSession) -> None:
"""Generate a new flow proposal from a novel session."""
if not await _check_daily_budget(session.account_id, db):
logger.warning("Daily proposal budget exceeded for account %s", session.account_id)
return
session_context = _build_session_context(session)
try:
provider = get_ai_provider(settings.get_model_for_action("open_chat"))
raw_response, _, _ = await provider.generate_json(
system_prompt=FLOW_GENERATION_PROMPT,
messages=[{"role": "user", "content": session_context}],
max_tokens=4096,
)
parsed = parse_llm_json(raw_response)
except Exception as e:
logger.warning("Knowledge Flywheel LLM call failed for session %s: %s", session.id, e)
return
title = parsed.get("title", session.problem_summary or "Untitled Flow")
domain = parsed.get("problem_domain", session.problem_domain)
# Check for similar pending proposals
existing = await _find_similar_pending_proposal(title, domain, session.account_id, db)
if existing:
# Merge into existing proposal
existing.supporting_session_count += 1
sids = existing.supporting_session_ids or []
sids.append(str(session.id))
existing.supporting_session_ids = sids
existing.confidence_score = min(1.0, existing.confidence_score + 0.1)
logger.info(
"Merged session %s into existing proposal %s (now %d supporting)",
session.id, existing.id, existing.supporting_session_count,
)
return
proposal = FlowProposal(
id=uuid.uuid4(),
account_id=session.account_id,
team_id=session.team_id,
source_session_id=session.id,
proposal_type="new_flow",
title=title,
description=parsed.get("description"),
proposed_flow_data={
"tree_structure": parsed.get("tree_structure", {}),
"match_keywords": parsed.get("match_keywords", []),
},
confidence_score=session.confidence_score,
supporting_session_ids=[str(session.id)],
problem_domain=domain,
status="pending",
)
db.add(proposal)
await notify("proposal.pending", proposal.account_id, {
"title": proposal.title,
"proposal_type": proposal.proposal_type,
"problem_domain": proposal.problem_domain or "General",
"link": "/review-queue",
}, db)
logger.info("Created new_flow proposal for session %s: %s", session.id, title)
async def _propose_enhancement(session: AISession, db: AsyncSession) -> None:
"""Generate an enhancement proposal for an existing flow."""
if not session.matched_flow_id:
# Fallback to new flow if no match
await _propose_new_flow(session, db)
return
if not await _check_daily_budget(session.account_id, db):
logger.warning("Daily proposal budget exceeded for account %s", session.account_id)
return
# Load the matched flow
result = await db.execute(
select(Tree).where(Tree.id == session.matched_flow_id)
)
matched_flow = result.scalar_one_or_none()
if not matched_flow:
await _propose_new_flow(session, db)
return
session_context = _build_session_context(session)
flow_json = json.dumps(matched_flow.tree_structure, indent=None)
if len(flow_json) > 4000:
flow_json = flow_json[:4000] + "... [truncated]"
prompt_content = (
f"## EXISTING FLOW\n"
f"Name: {matched_flow.name}\n"
f"Structure:\n{flow_json}\n\n"
f"## SESSION THAT DIVERGED\n"
f"{session_context}"
)
try:
provider = get_ai_provider(settings.get_model_for_action("open_chat"))
raw_response, _, _ = await provider.generate_json(
system_prompt=ENHANCEMENT_PROMPT,
messages=[{"role": "user", "content": prompt_content}],
max_tokens=4096,
)
parsed = parse_llm_json(raw_response)
except Exception as e:
logger.warning("Knowledge Flywheel enhancement LLM call failed for session %s: %s", session.id, e)
return
title = parsed.get("title", f"Enhancement: {session.problem_summary or 'Flow update'}")
diff_description = parsed.get("diff_description", "Session diverged from existing flow")
proposal = FlowProposal(
id=uuid.uuid4(),
account_id=session.account_id,
team_id=session.team_id,
source_session_id=session.id,
proposal_type="enhancement",
target_flow_id=session.matched_flow_id,
title=title,
description=diff_description,
proposed_flow_data={
"new_nodes": parsed.get("new_nodes", []),
"modified_options": parsed.get("modified_options", []),
},
proposed_diff={
"diff_description": diff_description,
"new_nodes": parsed.get("new_nodes", []),
"modified_options": parsed.get("modified_options", []),
},
confidence_score=session.confidence_score,
supporting_session_ids=[str(session.id)],
problem_domain=session.problem_domain,
status="pending",
)
db.add(proposal)
await notify("proposal.pending", proposal.account_id, {
"title": proposal.title,
"proposal_type": proposal.proposal_type,
"problem_domain": proposal.problem_domain or "General",
"link": "/review-queue",
}, db)
logger.info(
"Created enhancement proposal for flow %s from session %s: %s",
session.matched_flow_id, session.id, title,
)

View File

@@ -0,0 +1,72 @@
"""Background scheduler for Knowledge Flywheel analysis.
Runs every 5 minutes via APScheduler, picks up AISession entries
with analysis_status='pending' and runs flow proposal analysis.
Each session is committed individually to prevent a single failure
from rolling back all progress or causing duplicate proposals.
"""
import logging
from sqlalchemy import select
from app.core.database import async_session_maker
from app.models.ai_session import AISession
from app.services.knowledge_flywheel import analyze_session
logger = logging.getLogger(__name__)
async def process_pending_analyses() -> None:
"""Process resolved sessions awaiting Knowledge Flywheel analysis."""
async with async_session_maker() as db:
try:
result = await db.execute(
select(AISession.id)
.where(AISession.analysis_status == "pending")
.order_by(AISession.resolved_at.asc())
.limit(10)
)
session_ids = [row[0] for row in result.all()]
except Exception as e:
logger.error("Knowledge Flywheel scheduler query error: %s", e)
return
if not session_ids:
return
logger.info("Processing %d pending Knowledge Flywheel analyses", len(session_ids))
# Process each session in its own DB session to isolate failures
for session_id in session_ids:
async with async_session_maker() as db:
try:
result = await db.execute(
select(AISession).where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session or session.analysis_status != "pending":
continue
await analyze_session(session, db)
session.analysis_status = "completed"
await db.commit()
logger.info("Knowledge Flywheel completed for session %s", session_id)
except Exception as e:
await db.rollback()
logger.warning(
"Knowledge Flywheel failed for session %s: %s",
session_id, e,
)
# Mark as failed in a separate transaction
try:
async with async_session_maker() as db2:
result = await db2.execute(
select(AISession).where(AISession.id == session_id)
)
s = result.scalar_one_or_none()
if s:
s.analysis_status = "failed"
await db2.commit()
except Exception:
logger.error("Failed to mark session %s as failed", session_id)

View File

@@ -0,0 +1,334 @@
"""Knowledge Gap Detection Service.
Aggregates signals from AI sessions to identify gaps in the knowledge base.
Results are served by the analytics API and cached for 1 hour.
Signals:
1. Frequent free-text escapes — FlowPilot's options didn't cover a common scenario
2. High escalation rate by domain — domains where engineers can't self-resolve
3. Discovery-mode resolutions — novel problems solved without flow guidance
4. Repeated unmatched patterns — keyword-frequency based (Phase 4: embedding clustering)
"""
import logging
from collections import Counter
from datetime import datetime, timezone, timedelta
from typing import Any, Optional
from uuid import UUID
from pydantic import BaseModel
from sqlalchemy import select, func, case, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_session import AISession
from app.models.ai_session_step import AISessionStep
from app.models.tree import Tree
logger = logging.getLogger(__name__)
# Cache for expensive gap analysis
_cache: dict[str, Any] = {}
_cache_expiry: dict[str, datetime] = {}
CACHE_TTL = timedelta(hours=1)
class KnowledgeGap(BaseModel):
gap_type: str # "weak_options" | "high_escalation" | "uncharted_territory" | "repeated_pattern"
domain: str | None = None
severity: str # "high" | "medium" | "low"
title: str
description: str
evidence: dict[str, Any] = {}
suggested_action: str
class KnowledgeGapReport(BaseModel):
generated_at: datetime
gaps: list[KnowledgeGap]
async def get_knowledge_gaps(
account_id: UUID,
db: AsyncSession,
period_days: int = 30,
) -> KnowledgeGapReport:
"""Generate a knowledge gap report for the account.
Results are cached for 1 hour per account.
"""
cache_key = f"gaps:{account_id}:{period_days}"
now = datetime.now(timezone.utc)
if cache_key in _cache and _cache_expiry.get(cache_key, now) > now:
return _cache[cache_key]
period_start = now - timedelta(days=period_days)
gaps: list[KnowledgeGap] = []
# Signal 1: Frequent free-text escapes
signal1 = await _detect_weak_options(account_id, period_start, db)
gaps.extend(signal1)
# Signal 2: High escalation rate by domain
signal2 = await _detect_high_escalation(account_id, period_start, db)
gaps.extend(signal2)
# Signal 3: Discovery-mode resolutions
signal3 = await _detect_uncharted_territory(account_id, period_start, db)
gaps.extend(signal3)
# Signal 4: Repeated unmatched patterns (keyword-based for Phase 3)
signal4 = await _detect_repeated_patterns(account_id, period_start, db)
gaps.extend(signal4)
# Sort by severity (high > medium > low)
severity_order = {"high": 0, "medium": 1, "low": 2}
gaps.sort(key=lambda g: severity_order.get(g.severity, 3))
report = KnowledgeGapReport(generated_at=now, gaps=gaps)
_cache[cache_key] = report
_cache_expiry[cache_key] = now + CACHE_TTL
return report
async def _detect_weak_options(
account_id: UUID,
period_start: datetime,
db: AsyncSession,
) -> list[KnowledgeGap]:
"""Signal 1: Find questions where engineers frequently use free-text escapes."""
# Count free-text usage per step context_message (the question asked)
result = await db.execute(
select(
AISessionStep.context_message,
func.count(AISessionStep.id).label("total"),
func.sum(case((AISessionStep.was_free_text.is_(True), 1), else_=0)).label("free_text_count"),
)
.join(AISession, AISessionStep.session_id == AISession.id)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISessionStep.step_type == "question",
AISessionStep.context_message.isnot(None),
AISessionStep.responded_at.isnot(None),
)
.group_by(AISessionStep.context_message)
.having(func.count(AISessionStep.id) >= 3) # Minimum sample size
.order_by(func.sum(case((AISessionStep.was_free_text.is_(True), 1), else_=0)).desc())
.limit(5)
)
gaps = []
for row in result.all():
context_msg, total_raw, free_text_raw = row
total = int(total_raw or 0)
free_text_count = int(free_text_raw or 0)
if total == 0 or not free_text_count:
continue
rate = free_text_count / total
if rate < 0.3:
continue
severity = "high" if rate > 0.6 else "medium"
gaps.append(KnowledgeGap(
gap_type="weak_options",
severity=severity,
title=f"Weak options: {(context_msg or '')[:80]}",
description=(
f"Engineers used free-text input {free_text_count}/{total} times "
f"({rate:.0%}) when asked this question. The predefined options "
f"may not cover common scenarios."
),
evidence={
"context_message": context_msg,
"total_responses": total,
"free_text_count": free_text_count,
"free_text_rate": round(rate, 3),
},
suggested_action="Review the free-text responses and add common answers as options.",
))
return gaps
async def _detect_high_escalation(
account_id: UUID,
period_start: datetime,
db: AsyncSession,
) -> list[KnowledgeGap]:
"""Signal 2: Find domains with >40% escalation rate."""
result = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("total"),
func.sum(case(
(AISession.status == "resolved", 1), else_=0
)).label("resolved"),
func.sum(case(
(AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0
)).label("escalated"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_domain.isnot(None),
AISession.status.in_(["resolved", "escalated", "requesting_escalation"]),
)
.group_by(AISession.problem_domain)
.having(func.count(AISession.id) >= 3) # Minimum sample
)
gaps = []
for row in result.all():
domain, total_raw, resolved_raw, escalated_raw = row
total = int(total_raw or 0)
resolved = int(resolved_raw or 0)
escalated = int(escalated_raw or 0)
if total == 0 or not escalated:
continue
escalation_rate = escalated / total
if escalation_rate < 0.4:
continue
severity = "high" if escalation_rate > 0.6 else "medium"
gaps.append(KnowledgeGap(
gap_type="high_escalation",
domain=domain,
severity=severity,
title=f"High escalation rate in {domain}",
description=(
f"{escalated}/{total} sessions ({escalation_rate:.0%}) in {domain} "
f"were escalated. Only {resolved} resolved independently."
),
evidence={
"domain": domain,
"total": total,
"resolved": resolved,
"escalated": escalated,
"escalation_rate": round(escalation_rate, 3),
},
suggested_action=f"Create or improve troubleshooting flows for {domain} issues.",
))
return gaps
async def _detect_uncharted_territory(
account_id: UUID,
period_start: datetime,
db: AsyncSession,
) -> list[KnowledgeGap]:
"""Signal 3: Find discovery-mode resolutions (novel problems solved without flows)."""
result = await db.execute(
select(
AISession.problem_domain,
func.count(AISession.id).label("count"),
)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.status == "resolved",
AISession.confidence_tier == "discovery",
)
.group_by(AISession.problem_domain)
.having(func.count(AISession.id) >= 2)
.order_by(func.count(AISession.id).desc())
.limit(5)
)
gaps = []
for row in result.all():
domain, count = row
severity = "high" if count >= 5 else "medium" if count >= 3 else "low"
domain_label = domain or "unknown domain"
gaps.append(KnowledgeGap(
gap_type="uncharted_territory",
domain=domain,
severity=severity,
title=f"Novel resolutions in {domain_label}",
description=(
f"{count} sessions in {domain_label} were resolved in discovery mode "
f"(no matching flow, low confidence). These represent knowledge capture "
f"opportunities — check the Review Queue for auto-generated proposals."
),
evidence={
"domain": domain,
"discovery_resolution_count": count,
},
suggested_action="Review pending flow proposals or create flows from these session patterns.",
))
return gaps
async def _detect_repeated_patterns(
account_id: UUID,
period_start: datetime,
db: AsyncSession,
) -> list[KnowledgeGap]:
"""Signal 4: Find repeated unmatched intake patterns (keyword-frequency based).
Phase 3 uses keyword frequency on problem_summary. Phase 4 will use
embedding clustering for deeper semantic analysis.
"""
# Get problem summaries from unmatched sessions
result = await db.execute(
select(AISession.problem_summary, AISession.problem_domain)
.where(
AISession.account_id == account_id,
AISession.created_at >= period_start,
AISession.problem_summary.isnot(None),
AISession.matched_flow_id.is_(None),
)
.limit(200)
)
rows = result.all()
if len(rows) < 3:
return []
# Extract keywords from summaries and count frequency
word_counts: Counter[str] = Counter()
domain_for_word: dict[str, str | None] = {}
for summary, domain in rows:
if not summary:
continue
words = set(summary.lower().split())
# Filter out common stop words and short words
stop_words = {"the", "a", "an", "is", "are", "was", "were", "in", "on", "at",
"to", "for", "of", "and", "or", "not", "can", "can't", "with",
"from", "by", "this", "that", "it", "its", "has", "have", "had",
"user", "users", "issue", "error", "problem"}
keywords = {w for w in words if len(w) > 3 and w not in stop_words}
for kw in keywords:
word_counts[kw] += 1
if kw not in domain_for_word:
domain_for_word[kw] = domain
gaps = []
# Find keywords that appear in many unmatched sessions
for keyword, count in word_counts.most_common(3):
if count < 3:
continue
severity = "medium" if count >= 5 else "low"
domain = domain_for_word.get(keyword)
gaps.append(KnowledgeGap(
gap_type="repeated_pattern",
domain=domain,
severity=severity,
title=f"Recurring unmatched pattern: '{keyword}'",
description=(
f"The keyword '{keyword}' appeared in {count} sessions that had no "
f"matching flow. This may indicate a systematic knowledge gap."
),
evidence={
"keyword": keyword,
"unmatched_session_count": count,
"domain": domain,
},
suggested_action=f"Search for '{keyword}' in recent sessions and consider creating a flow.",
))
return gaps

View File

@@ -0,0 +1,39 @@
"""Shared utilities for parsing LLM responses."""
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def strip_markdown_fences(text: str) -> str:
"""Strip markdown code fences from LLM output, returning raw content.
Use this when you need just the stripping without JSON parsing
(e.g., when the caller has its own error handling for json.loads).
"""
text = text.strip()
if text.startswith("```"):
lines = text.split("\n")
lines = [line for line in lines if not line.strip().startswith("```")]
text = "\n".join(lines).strip()
return text
def parse_llm_json(raw_text: str) -> dict[str, Any]:
"""Parse JSON from LLM response, handling common quirks.
Strips markdown code fences (```json ... ``` or ``` ... ```) if present,
then parses the remaining text as JSON.
Raises:
ValueError: If the text is not valid JSON after fence stripping.
"""
text = strip_markdown_fences(raw_text)
try:
return json.loads(text)
except json.JSONDecodeError as e:
logger.warning("LLM JSON parse failed: %s — raw: %.300s", e, text)
raise ValueError(f"Invalid JSON from LLM: {e}") from e

View File

@@ -0,0 +1,420 @@
"""Notification service — dispatches in-app + external notifications.
Entry point: `notify(event, account_id, payload, db)`.
Retry engine: `retry_failed_notifications(db)` called by APScheduler.
"""
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.email import EmailService
from app.models.notification import Notification
from app.models.notification_config import NotificationConfig
from app.models.notification_log import NotificationLog
from app.models.user import User
logger = logging.getLogger(__name__)
# Exponential backoff schedule (seconds): 30s, 2m, 10m
_RETRY_DELAYS = [30, 120, 600]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def notify(
event: str,
account_id: uuid.UUID,
payload: dict[str, Any],
db: AsyncSession,
target_user_ids: Optional[list[uuid.UUID]] = None,
) -> None:
"""Main entry point — create in-app notifications + route to external channels.
IMPORTANT: This function does NOT commit or rollback. The caller owns the transaction.
In-app notifications are added to the session (flushed, not committed).
External channel delivery is fire-and-forget — failures are logged, not raised.
"""
try:
recipients = await _resolve_recipients(account_id, target_user_ids, db)
title = _build_notification_title(event, payload)
body = _build_notification_body(event, payload)
link = _build_notification_link(event, payload)
# Create in-app notification for each recipient
for user in recipients:
notification = Notification(
account_id=account_id,
user_id=user.id,
event=event,
title=title,
body=body,
link=link,
)
db.add(notification)
await db.flush()
# Route to active external channels (fire-and-forget per channel)
configs = await _get_active_configs(account_id, event, db)
for config in configs:
try:
await _deliver_to_channel(config, event, payload, db)
except Exception:
logger.exception(
"External delivery failed for config=%s event=%s", config.id, event
)
except Exception:
logger.exception("Failed to process notification event=%s account=%s", event, account_id)
async def retry_failed_notifications(db: AsyncSession) -> int:
"""Retry failed notification deliveries. Called by APScheduler."""
now = datetime.now(timezone.utc)
result = await db.execute(
select(NotificationLog)
.where(NotificationLog.status == "retrying")
.where(NotificationLog.next_retry_at <= now)
)
logs = result.scalars().all()
if not logs:
return 0
logger.info("Retrying %d failed notification deliveries", len(logs))
for log in logs:
# Load the config for this log entry
config_result = await db.execute(
select(NotificationConfig).where(NotificationConfig.id == log.notification_config_id)
)
config = config_result.scalar_one_or_none()
if not config or not config.is_active:
log.status = "exhausted"
log.last_error = "Config disabled or deleted"
continue
try:
await _attempt_delivery(config, log.event, log.payload)
log.status = "sent"
log.delivered_at = datetime.now(timezone.utc)
log.last_error = None
logger.info("Retry succeeded for log=%s event=%s", log.id, log.event)
except Exception as exc:
log.retry_count += 1
log.last_error = str(exc)[:1000]
if log.retry_count >= log.max_retries:
log.status = "exhausted"
logger.warning(
"Notification exhausted after %d retries: log=%s event=%s",
log.retry_count, log.id, log.event,
)
else:
delay = _RETRY_DELAYS[min(log.retry_count, len(_RETRY_DELAYS) - 1)]
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
logger.info(
"Notification retry %d/%d scheduled in %ds: log=%s",
log.retry_count, log.max_retries, delay, log.id,
)
await db.commit()
return len(logs)
async def send_test_notification(
config: NotificationConfig,
) -> tuple[bool, str]:
"""Send a test message through a channel config. Returns (success, message)."""
event = "test"
payload: dict[str, Any] = {}
try:
await _attempt_delivery(config, event, payload)
return True, "Test notification sent successfully"
except Exception as exc:
return False, f"Delivery failed: {exc}"
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
async def _get_active_configs(
account_id: uuid.UUID,
event: str,
db: AsyncSession,
) -> list[NotificationConfig]:
"""Get configs where channel is active and event is enabled."""
result = await db.execute(
select(NotificationConfig)
.where(NotificationConfig.account_id == account_id)
.where(NotificationConfig.is_active.is_(True))
)
configs = result.scalars().all()
# Filter to configs where this event is enabled
return [
c for c in configs
if c.events_enabled and c.events_enabled.get(event, False)
]
async def _resolve_recipients(
account_id: uuid.UUID,
target_user_ids: Optional[list[uuid.UUID]],
db: AsyncSession,
) -> list[User]:
"""Resolve notification recipients. Defaults to team admins + account owners + admins."""
if target_user_ids:
result = await db.execute(
select(User)
.where(User.id.in_(target_user_ids))
.where(User.account_id == account_id) # enforce tenant boundary
.where(User.is_active.is_(True))
)
return list(result.scalars().all())
# Default: account owners, admins, and team admins
result = await db.execute(
select(User)
.where(User.account_id == account_id)
.where(User.is_active.is_(True))
)
users = result.scalars().all()
return [
u for u in users
if u.account_role in ("owner", "admin") or u.is_team_admin
]
async def _deliver_to_channel(
config: NotificationConfig,
event: str,
payload: dict[str, Any],
db: AsyncSession,
) -> None:
"""Attempt delivery and create a NotificationLog entry."""
log = NotificationLog(
notification_config_id=config.id,
event=event,
payload=payload,
)
try:
await _attempt_delivery(config, event, payload)
log.status = "sent"
log.delivered_at = datetime.now(timezone.utc)
except Exception as exc:
log.status = "retrying"
log.retry_count = 0
log.last_error = str(exc)[:1000]
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=_RETRY_DELAYS[0])
logger.warning(
"Notification delivery failed (will retry): config=%s event=%s error=%s",
config.id, event, exc,
)
db.add(log)
async def _attempt_delivery(
config: NotificationConfig,
event: str,
payload: dict[str, Any],
) -> None:
"""Dispatch to the appropriate channel. Raises on failure."""
if config.channel == "email":
await _send_email(config, event, payload)
elif config.channel == "slack_webhook":
if not config.webhook_url:
raise ValueError("Slack webhook URL not configured")
await _send_slack_message(config.webhook_url, event, payload)
elif config.channel == "teams_webhook":
if not config.webhook_url:
raise ValueError("Teams webhook URL not configured")
await _send_teams_message(config.webhook_url, event, payload)
else:
raise ValueError(f"Unknown channel: {config.channel}")
async def _send_email(
config: NotificationConfig,
event: str,
payload: dict[str, Any],
) -> None:
"""Send notification via email using EmailService."""
title = _build_notification_title(event, payload)
body = _build_notification_body(event, payload)
link = _build_notification_link(event, payload)
full_link = None
if link and settings.FRONTEND_URL:
full_link = f"{settings.FRONTEND_URL.rstrip('/')}{link}"
recipients = config.email_addresses or []
if not recipients:
raise ValueError("No email addresses configured for email channel")
failures = []
for email in recipients:
success = await EmailService.send_notification_email(
to_email=email,
title=title,
body=body,
link_url=full_link,
)
if not success:
failures.append(email)
if failures:
raise RuntimeError(f"Failed to send notification email to: {', '.join(failures)}")
async def _send_slack_message(
webhook_url: str,
event: str,
payload: dict[str, Any],
) -> None:
"""POST notification to Slack incoming webhook."""
title = _build_notification_title(event, payload)
body = _build_notification_body(event, payload)
link = _build_notification_link(event, payload)
blocks: list[dict[str, Any]] = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"\U0001f514 {title}", "emoji": True},
},
{
"type": "section",
"text": {"type": "mrkdwn", "text": body},
},
]
if link and settings.FRONTEND_URL:
full_url = f"{settings.FRONTEND_URL.rstrip('/')}{link}"
blocks.append({
"type": "actions",
"elements": [{
"type": "button",
"text": {"type": "plain_text", "text": "Open in ResolutionFlow", "emoji": True},
"url": full_url,
}],
})
slack_payload = {"blocks": blocks}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json=slack_payload)
if resp.status_code != 200 or resp.text.strip() != "ok":
raise RuntimeError(
f"Slack webhook failed (status={resp.status_code}): {resp.text[:200]}"
)
async def _send_teams_message(
webhook_url: str,
event: str,
payload: dict[str, Any],
) -> None:
"""POST notification to Microsoft Teams incoming webhook (Adaptive Card)."""
title = _build_notification_title(event, payload)
body = _build_notification_body(event, payload)
link = _build_notification_link(event, payload)
card_body: list[dict[str, Any]] = [
{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium"},
{"type": "TextBlock", "text": body, "wrap": True},
]
actions: list[dict[str, Any]] = []
if link and settings.FRONTEND_URL:
full_url = f"{settings.FRONTEND_URL.rstrip('/')}{link}"
actions.append({
"type": "Action.OpenUrl",
"title": "Open in ResolutionFlow",
"url": full_url,
})
teams_payload = {
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": card_body,
"actions": actions,
},
}],
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json=teams_payload)
if resp.status_code not in (200, 202):
raise RuntimeError(
f"Teams webhook returned {resp.status_code}: {resp.text[:200]}"
)
# ---------------------------------------------------------------------------
# Content builders
# ---------------------------------------------------------------------------
def _build_notification_title(event: str, payload: dict[str, Any]) -> str:
"""Human-readable title per event type."""
titles = {
"session.escalated": "Session escalated by {engineer_name}",
"session.high_priority": "High-priority session started: {ticket_number}",
"proposal.pending": "New flow proposal: {title}",
"proposal.approved": "Flow proposal approved: {title}",
"knowledge_gap.detected": "Knowledge gap detected: {gap_type}",
"test": "Test Notification from ResolutionFlow",
}
template = titles.get(event, f"Notification: {event}")
try:
return template.format(**payload)
except KeyError:
return template
def _build_notification_body(event: str, payload: dict[str, Any]) -> str:
"""Body text per event type."""
bodies = {
"session.escalated": "Engineer {engineer_name} has escalated a FlowPilot session and needs assistance.",
"session.high_priority": "A new high-priority troubleshooting session has been started for ticket {ticket_number}.",
"proposal.pending": "A new flow proposal \"{title}\" is awaiting review in the review queue.",
"proposal.approved": "The flow proposal \"{title}\" has been approved and is ready for use.",
"knowledge_gap.detected": "A {gap_type} knowledge gap has been identified. Review recommended.",
"test": "This is a test notification to verify your notification channel is working correctly.",
}
template = bodies.get(event, f"Event: {event}")
try:
return template.format(**payload)
except KeyError:
return template
def _build_notification_link(event: str, payload: dict[str, Any]) -> Optional[str]:
"""In-app link per event type. Returns path (no host)."""
links: dict[str, str] = {
"session.escalated": "/pilot/{session_id}",
"session.high_priority": "/pilot/{session_id}",
"proposal.pending": "/review-queue",
"proposal.approved": "/review-queue",
"knowledge_gap.detected": "/analytics/flowpilot",
}
template = links.get(event)
if template is None:
return None
try:
return template.format(**payload)
except KeyError:
return template

View File

@@ -0,0 +1,3 @@
from app.services.psa.autotask.provider import AutotaskProvider
__all__ = ["AutotaskProvider"]

View File

@@ -0,0 +1,72 @@
"""Autotask PSA provider stub. Full implementation planned for Phase 5."""
from __future__ import annotations
from app.services.psa.base import PSAProvider
from app.services.psa.types import (
ConnectionTestResult,
PSATicket,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
)
class AutotaskProvider(PSAProvider):
"""Stub provider for Autotask PSA. All methods raise NotImplementedError.
Full implementation is planned for Phase 5. This stub allows the provider
to be registered and discovered without causing import errors.
"""
async def test_connection(self) -> ConnectionTestResult:
raise NotImplementedError("Autotask integration coming soon")
async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Autotask integration coming soon")
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
raise NotImplementedError("Autotask integration coming soon")
async def post_note(
self,
ticket_id: str,
text: str,
note_type: str,
member_id: str | None = None,
) -> PSANote:
raise NotImplementedError("Autotask integration coming soon")
async def update_ticket_status(
self,
ticket_id: str,
status_id: int,
) -> PSATicket:
raise NotImplementedError("Autotask integration coming soon")
async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]:
raise NotImplementedError("Autotask integration coming soon")
async def list_companies(self, **filters) -> list[PSACompany]:
raise NotImplementedError("Autotask integration coming soon")
async def get_company(self, company_id: str) -> PSACompany:
raise NotImplementedError("Autotask integration coming soon")
async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Autotask integration coming soon")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Autotask integration coming soon")
async def create_time_entry(
self,
ticket_id: str,
member_id: str,
hours: float,
notes: str | None = None,
work_type: str | None = None,
) -> PSATimeEntry:
raise NotImplementedError("Autotask integration coming soon")

View File

@@ -11,6 +11,7 @@ from .types import (
PSACompany, PSACompany,
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry,
) )
@@ -66,3 +67,14 @@ class PSAProvider(ABC):
@abstractmethod @abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]: async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
... ...
@abstractmethod
async def create_time_entry(
self,
ticket_id: str,
member_id: str,
hours: float,
notes: str | None = None,
work_type: str | None = None,
) -> PSATimeEntry:
...

View File

@@ -15,6 +15,7 @@ from app.services.psa.types import (
PSACompany, PSACompany,
PSAMember, PSAMember,
PSAConfiguration, PSAConfiguration,
PSATimeEntry,
) )
from .client import ConnectWiseClient from .client import ConnectWiseClient
@@ -514,6 +515,37 @@ class ConnectWiseProvider(PSAProvider):
psa_cache.set(cache_key, ctx, ttl_seconds=300) psa_cache.set(cache_key, ctx, ttl_seconds=300)
return ctx return ctx
async def create_time_entry(
self,
ticket_id: str,
member_id: str,
hours: float,
notes: str | None = None,
work_type: str | None = None,
) -> PSATimeEntry:
"""Create a time entry on a CW ticket via POST /time/entries."""
payload: dict = {
"chargeToId": int(ticket_id),
"chargeToType": "ServiceTicket",
"member": {"id": int(member_id)},
"timeStart": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"actualHours": hours,
}
if notes:
payload["notes"] = notes[:2000] # CW limit
if work_type:
payload["workType"] = {"name": work_type}
data = await self._client.post("/time/entries", payload)
return PSATimeEntry(
id=str(data["id"]),
ticket_id=ticket_id,
member_id=member_id,
hours=data.get("actualHours", hours),
notes=data.get("notes"),
created_at=data.get("timeStart"),
)
# ── Private helpers ─────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────
@staticmethod @staticmethod

View File

@@ -0,0 +1,3 @@
from app.services.psa.halopsa.provider import HaloPSAProvider
__all__ = ["HaloPSAProvider"]

View File

@@ -0,0 +1,72 @@
"""Halo PSA provider stub. Full implementation planned for Phase 5."""
from __future__ import annotations
from app.services.psa.base import PSAProvider
from app.services.psa.types import (
ConnectionTestResult,
PSATicket,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
)
class HaloPSAProvider(PSAProvider):
"""Stub provider for Halo PSA. All methods raise NotImplementedError.
Full implementation is planned for Phase 5. This stub allows the provider
to be registered and discovered without causing import errors.
"""
async def test_connection(self) -> ConnectionTestResult:
raise NotImplementedError("Halo PSA integration coming soon")
async def get_ticket(self, ticket_id: str) -> PSATicket:
raise NotImplementedError("Halo PSA integration coming soon")
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
raise NotImplementedError("Halo PSA integration coming soon")
async def post_note(
self,
ticket_id: str,
text: str,
note_type: str,
member_id: str | None = None,
) -> PSANote:
raise NotImplementedError("Halo PSA integration coming soon")
async def update_ticket_status(
self,
ticket_id: str,
status_id: int,
) -> PSATicket:
raise NotImplementedError("Halo PSA integration coming soon")
async def get_ticket_statuses(self, board_id: int) -> list[PSAStatus]:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_companies(self, **filters) -> list[PSACompany]:
raise NotImplementedError("Halo PSA integration coming soon")
async def get_company(self, company_id: str) -> PSACompany:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Halo PSA integration coming soon")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Halo PSA integration coming soon")
async def create_time_entry(
self,
ticket_id: str,
member_id: str,
hours: float,
notes: str | None = None,
work_type: str | None = None,
) -> PSATimeEntry:
raise NotImplementedError("Halo PSA integration coming soon")

View File

@@ -31,6 +31,32 @@ async def get_provider_for_account(
provider="unknown", provider="unknown",
) )
return _instantiate_provider(connection)
async def get_provider_for_connection(
connection_id: UUID, db: AsyncSession
) -> PSAProvider:
"""Look up a specific PSA connection by ID, decrypt credentials, instantiate provider."""
result = await db.execute(
select(PsaConnection).where(
PsaConnection.id == connection_id,
PsaConnection.is_active.is_(True),
)
)
connection = result.scalar_one_or_none()
if not connection:
raise PSAConnectionError(
"PSA connection not found or inactive.",
provider="unknown",
)
return _instantiate_provider(connection)
def _instantiate_provider(connection: PsaConnection) -> PSAProvider:
"""Create the appropriate provider instance from a connection record."""
if connection.provider == "connectwise": if connection.provider == "connectwise":
from app.services.psa.connectwise.client import ConnectWiseClient from app.services.psa.connectwise.client import ConnectWiseClient
from app.services.psa.connectwise.provider import ConnectWiseProvider from app.services.psa.connectwise.provider import ConnectWiseProvider

View File

@@ -57,6 +57,16 @@ class PSAConfiguration(BaseModel):
company_name: str | None = None company_name: str | None = None
class PSATimeEntry(BaseModel):
id: str
ticket_id: str
member_id: str | None = None
hours: float
notes: str | None = None
work_type: str | None = None
created_at: str | None = None
class NoteType: class NoteType:
INTERNAL_ANALYSIS = "internal_analysis" INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution" RESOLUTION = "resolution"

View File

@@ -0,0 +1,432 @@
"""PSA Documentation Push Service.
Generates structured documentation from FlowPilot AI sessions and pushes
it back to ConnectWise as internal notes + optional time entries.
"""
import logging
import math
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_session import AISession
from app.models.psa_activity_log import PsaActivityLog
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_connection
from app.services.psa.types import NoteType
from app.services.redaction_service import apply_redaction_to_text
logger = logging.getLogger(__name__)
# Default flowpilot_settings values
DEFAULT_SETTINGS = {
"auto_push": True,
"auto_time_entry": True,
"time_rounding": "15min", # "15min", "30min", "exact", "none"
"note_visibility": "internal", # "internal", "both"
"include_diagnostic_steps": True,
}
def _get_setting(connection: PsaConnection, key: str) -> Any:
"""Get a flowpilot setting with default fallback."""
settings = connection.flowpilot_settings or {}
return settings.get(key, DEFAULT_SETTINGS.get(key))
def _round_hours(hours: float, rounding: str) -> float:
"""Round hours according to the rounding setting."""
if rounding == "exact":
return round(hours, 2)
elif rounding == "30min":
return math.ceil(hours * 2) / 2
else: # default 15min
return math.ceil(hours * 4) / 4
def _format_datetime(dt: datetime | None) -> str:
"""Format a datetime for display in notes."""
if not dt:
return "N/A"
return dt.strftime("%Y-%m-%d %I:%M %p UTC")
def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
"""Format a resolved session as a plain-text note for CW."""
lines = [
"═══ FlowPilot Session Documentation ═══",
f"Session: {session.id}",
]
# Engineer name from relationship if loaded, otherwise user_id
engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'name'):
lines.append(f"Engineer: {engineer_name.name}")
lines.extend([
f"Date: {_format_datetime(session.resolved_at)}",
f"Started: {_format_datetime(session.created_at)}",
f"Ended: {_format_datetime(session.resolved_at)}",
])
# Duration
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
lines.append(f"Duration: {minutes}m")
else:
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
if session.problem_domain:
lines.append(f"Domain: {session.problem_domain}")
# Diagnostic steps
if include_steps and session.steps:
lines.append("")
lines.append("── Diagnosis Path ──")
for step in session.steps:
content = step.content or {}
step_type = content.get("type", step.step_type).capitalize()
description = content.get("text", "")
response_text = ""
if step.was_skipped:
response_text = "Skipped"
elif step.selected_option:
# Try to find the label
if step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response_text = opt.get("label", step.selected_option)
break
else:
response_text = step.selected_option
else:
response_text = step.selected_option
elif step.free_text_input:
response_text = step.free_text_input
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
if response_text:
lines.append(f" → Response: {response_text}")
if step.action_result:
result = step.action_result
outcome = "Succeeded" if result.get("success") else "Did not resolve"
if details := result.get("details"):
outcome += f"{details}"
lines.append(f" → Result: {outcome}")
# Resolution
lines.append("")
lines.append("── Resolution ──")
lines.append(session.resolution_summary or "No resolution summary")
if session.resolution_action:
lines.append(session.resolution_action)
# Confidence
lines.append("")
lines.append("── AI Confidence ──")
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})")
# Timing section (always present)
lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}")
lines.append(f"End: {_format_datetime(session.resolved_at)}")
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot")
return "\n".join(lines)
def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
"""Format an escalated session as a plain-text note for CW."""
lines = [
"═══ FlowPilot Escalation Documentation ═══",
f"Session: {session.id}",
]
engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'name'):
lines.append(f"Escalated by: {engineer_name.name}")
escalated_to = getattr(session, 'escalated_to', None)
if escalated_to and hasattr(escalated_to, 'name'):
lines.append(f"Escalated to: {escalated_to.name}")
else:
lines.append("Escalated to: Unassigned")
lines.extend([
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
f"Started: {_format_datetime(session.created_at)}",
])
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
# Work completed
if include_steps and session.steps:
lines.append("")
lines.append("── Work Completed ──")
for step in session.steps:
content = step.content or {}
description = content.get("text", "")
lines.append(f"{step.step_order + 1}. {description}")
# Escalation reason
lines.append("")
lines.append("── Escalation Reason ──")
lines.append(session.escalation_reason or "No reason provided")
# Escalation package details
pkg = session.escalation_package or {}
if hypotheses := pkg.get("remaining_hypotheses"):
lines.append("")
lines.append("── Remaining Hypotheses ──")
if isinstance(hypotheses, list):
for h in hypotheses:
lines.append(f"- {h}")
else:
lines.append(str(hypotheses))
if suggestions := pkg.get("suggested_next_steps"):
lines.append("")
lines.append("── Suggested Next Steps ──")
if isinstance(suggestions, list):
for s in suggestions:
lines.append(f"- {s}")
else:
lines.append(str(suggestions))
# Timing
lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}")
escalated_at = session.resolved_at or datetime.now(timezone.utc)
lines.append(f"Escalated: {_format_datetime(escalated_at)}")
if session.created_at:
delta = escalated_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot")
return "\n".join(lines)
async def push_documentation(
session: AISession,
user_id: UUID,
db: AsyncSession,
) -> dict[str, Any]:
"""Push session documentation to PSA ticket.
Returns:
{
"psa_push_status": "sent" | "pending_retry" | "failed" | "no_psa",
"psa_push_error": str | None,
"member_mapping_warning": str | None,
}
"""
if not session.psa_ticket_id or not session.psa_connection_id:
return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None}
# Load connection and check settings
result = await db.execute(
select(PsaConnection).where(PsaConnection.id == session.psa_connection_id)
)
connection = result.scalar_one_or_none()
if not connection:
return {"psa_push_status": "failed", "psa_push_error": "PSA connection not found", "member_mapping_warning": None}
if not _get_setting(connection, "auto_push"):
return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None}
# Format the note
include_steps = _get_setting(connection, "include_diagnostic_steps")
if session.status == "resolved":
note_text = format_resolution_note(session, include_steps=include_steps)
else:
note_text = format_escalation_note(session, include_steps=include_steps)
# Redact sensitive data
note_text, _ = apply_redaction_to_text(note_text)
# Determine note type
visibility = _get_setting(connection, "note_visibility")
note_type = NoteType.INTERNAL_ANALYSIS if visibility == "internal" else NoteType.DESCRIPTION
# Check member mapping for time entry
member_mapping_warning = None
member_mapping = None
if _get_setting(connection, "auto_time_entry") and _get_setting(connection, "time_rounding") != "none":
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == session.psa_connection_id,
PsaMemberMapping.user_id == user_id,
)
)
member_mapping = mapping_result.scalar_one_or_none()
if not member_mapping:
member_mapping_warning = "Map your CW account in Settings → Integrations to enable auto-logged time entries."
# Push to PSA
try:
provider = await get_provider_for_connection(session.psa_connection_id, db)
# Post the note
posted_note = await provider.post_note(
ticket_id=session.psa_ticket_id,
text=note_text,
note_type=note_type,
)
# Create time entry if member mapping exists
time_entry_hours: Optional[float] = None
if member_mapping and session.resolved_at and session.created_at:
try:
delta = session.resolved_at - session.created_at
hours = delta.total_seconds() / 3600
rounding = _get_setting(connection, "time_rounding")
rounded_hours = _round_hours(hours, rounding)
if rounded_hours > 0:
await provider.create_time_entry(
ticket_id=session.psa_ticket_id,
member_id=member_mapping.external_member_id,
hours=rounded_hours,
notes=f"FlowPilot session: {session.problem_summary or 'Troubleshooting'}",
)
time_entry_hours = rounded_hours
except Exception as e:
logger.warning("Failed to create time entry for session %s: %s", session.id, e)
# Don't fail the note push just because time entry failed
# Log PSA activity — note posted
try:
note_activity = PsaActivityLog(
account_id=session.account_id,
session_id=session.id,
activity_type="note_posted",
hours_logged=None,
psa_ticket_id=session.psa_ticket_id,
)
db.add(note_activity)
except Exception as e:
logger.warning("Failed to log PSA note activity for session %s: %s", session.id, e)
# Log time entry activity if one was created
if time_entry_hours is not None:
try:
time_activity = PsaActivityLog(
account_id=session.account_id,
session_id=session.id,
activity_type="time_entry_posted",
hours_logged=time_entry_hours,
psa_ticket_id=session.psa_ticket_id,
)
db.add(time_activity)
except Exception as e:
logger.warning("Failed to log PSA time entry activity for session %s: %s", session.id, e)
# Log success
log_entry = PsaPostLog(
id=uuid.uuid4(),
ai_session_id=session.id,
psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id,
note_type=note_type,
content_posted=note_text[:10000], # Truncate for storage
external_note_id=posted_note.id,
status="success",
posted_by=user_id,
)
db.add(log_entry)
return {
"psa_push_status": "sent",
"psa_push_error": None,
"member_mapping_warning": member_mapping_warning,
}
except Exception as e:
logger.warning("PSA push failed for session %s: %s", session.id, e)
# Log failure with retry scheduling
log_entry = PsaPostLog(
id=uuid.uuid4(),
ai_session_id=session.id,
psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id,
note_type=note_type,
content_posted=note_text[:10000],
status="pending_retry",
error_message=str(e)[:500],
retry_count=0,
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
posted_by=user_id,
)
db.add(log_entry)
return {
"psa_push_status": "pending_retry",
"psa_push_error": str(e)[:200],
"member_mapping_warning": member_mapping_warning,
}
async def retry_failed_push(
log_entry: PsaPostLog,
db: AsyncSession,
) -> bool:
"""Retry a failed PSA push. Returns True on success."""
try:
provider = await get_provider_for_connection(log_entry.psa_connection_id, db)
posted_note = await provider.post_note(
ticket_id=log_entry.ticket_id,
text=log_entry.content_posted,
note_type=log_entry.note_type,
)
log_entry.status = "success"
log_entry.external_note_id = posted_note.id
log_entry.error_message = None
log_entry.next_retry_at = None
return True
except Exception as e:
log_entry.retry_count += 1
log_entry.error_message = str(e)[:500]
if log_entry.retry_count >= 3:
log_entry.status = "failed"
log_entry.next_retry_at = None
else:
# Exponential backoff: 5min, 15min, 45min
backoff_minutes = 5 * (3 ** log_entry.retry_count)
log_entry.next_retry_at = datetime.now(timezone.utc) + timedelta(minutes=backoff_minutes)
logger.warning(
"PSA retry %d failed for log %s: %s",
log_entry.retry_count, log_entry.id, e,
)
return False

View File

@@ -0,0 +1,52 @@
"""Background scheduler for retrying failed PSA documentation pushes.
Runs every 5 minutes via APScheduler, picks up PsaPostLog entries
with status='pending_retry' and next_retry_at <= now.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import async_session_maker
from app.models.psa_post_log import PsaPostLog
from app.services.psa_documentation_service import retry_failed_push
logger = logging.getLogger(__name__)
async def process_pending_retries() -> None:
"""Process all pending PSA push retries that are due."""
async with async_session_maker() as db:
try:
result = await db.execute(
select(PsaPostLog)
.where(
PsaPostLog.status == "pending_retry",
PsaPostLog.next_retry_at <= datetime.now(timezone.utc),
PsaPostLog.retry_count < 3,
)
.limit(20) # Process in batches
)
entries = result.scalars().all()
if not entries:
return
logger.info("Processing %d pending PSA push retries", len(entries))
for entry in entries:
success = await retry_failed_push(entry, db)
if success:
logger.info("PSA retry succeeded for log %s", entry.id)
else:
logger.warning(
"PSA retry %d/%d failed for log %s",
entry.retry_count, 3, entry.id,
)
await db.commit()
except Exception as e:
logger.error("PSA retry scheduler error: %s", e)
await db.rollback()

View File

@@ -0,0 +1,165 @@
"""Generate and store embeddings for AI sessions for similar-session matching.
Uses Voyage AI (voyage-3.5, 1024 dims) via the shared embedding_service to
create vector representations of session content. Enables cosine similarity
search across sessions within the same account.
"""
import logging
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_session import AISession
from app.models.ai_session_embedding import AISessionEmbedding
from app.services.embedding_service import get_embedding
logger = logging.getLogger(__name__)
async def generate_session_embedding(session_id: UUID, db: AsyncSession) -> None:
"""Generate embedding for an AI session's content.
Builds a text chunk from the session's problem summary, resolution,
domain, and escalation reason, then embeds it via Voyage AI and
upserts into ai_session_embeddings.
"""
result = await db.execute(
select(AISession).where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
return
# Build text to embed — combine available session metadata
parts = []
if session.problem_summary:
parts.append(session.problem_summary)
if session.resolution_summary:
parts.append(f"Resolution: {session.resolution_summary}")
if session.problem_domain:
parts.append(f"Domain: {session.problem_domain}")
if session.escalation_reason:
parts.append(f"Escalation: {session.escalation_reason}")
if not parts:
return
chunk_text = " ".join(parts)
try:
embedding_vector = await get_embedding(chunk_text, input_type="document")
if not embedding_vector:
return
embedding_str = "[" + ",".join(str(v) for v in embedding_vector) + "]"
# Use a savepoint so failures don't poison the parent transaction
async with db.begin_nested():
# Check for existing embedding
existing = await db.execute(
select(AISessionEmbedding).where(
AISessionEmbedding.session_id == session_id
)
)
embed_record = existing.scalar_one_or_none()
if embed_record:
# Update existing
embed_record.chunk_text = chunk_text
await db.execute(
text(
"UPDATE ai_session_embeddings "
"SET embedding = :emb::vector, updated_at = now() "
"WHERE session_id = :sid"
),
{"emb": embedding_str, "sid": str(session_id)},
)
else:
# Insert new via raw SQL to include vector column
await db.execute(
text("""
INSERT INTO ai_session_embeddings
(id, session_id, account_id, chunk_text, embedding_model, embedding, created_at, updated_at)
VALUES
(gen_random_uuid(), :session_id, :account_id, :chunk_text, :model, :embedding::vector, now(), now())
"""),
{
"session_id": str(session_id),
"account_id": str(session.account_id),
"chunk_text": chunk_text,
"model": "voyage-3.5",
"embedding": embedding_str,
},
)
except Exception:
logger.warning(
"Failed to generate embedding for session %s", session_id, exc_info=True
)
async def find_similar_sessions(
session_id: UUID,
account_id: UUID,
db: AsyncSession,
limit: int = 5,
) -> list[dict]:
"""Find sessions similar to the given session using cosine similarity.
Returns a list of dicts with session metadata and similarity score,
ordered by highest similarity first.
"""
# Verify the source session has an embedding
check = await db.execute(
text(
"SELECT 1 FROM ai_session_embeddings "
"WHERE session_id = :sid AND embedding IS NOT NULL"
),
{"sid": str(session_id)},
)
if not check.first():
return []
# Cosine similarity search across all sessions in the account
result = await db.execute(
text("""
SELECT
e.session_id,
s.problem_summary,
s.problem_domain,
s.status,
s.resolution_summary,
s.created_at,
1 - (e.embedding <=> (
SELECT embedding FROM ai_session_embeddings WHERE session_id = :sid
)) as similarity
FROM ai_session_embeddings e
JOIN ai_sessions s ON s.id = e.session_id
WHERE e.account_id = :account_id
AND e.session_id != :sid
AND e.embedding IS NOT NULL
ORDER BY e.embedding <=> (
SELECT embedding FROM ai_session_embeddings WHERE session_id = :sid
)
LIMIT :lim
"""),
{
"sid": str(session_id),
"account_id": str(account_id),
"lim": limit,
},
)
rows = result.mappings().all()
return [
{
"id": str(row["session_id"]),
"problem_summary": row["problem_summary"],
"problem_domain": row["problem_domain"],
"status": row["status"],
"resolution_summary": row["resolution_summary"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"similarity": round(float(row["similarity"]), 3) if row["similarity"] else 0,
}
for row in rows
]

View File

@@ -5,7 +5,6 @@ flow with fallback branches, powered by AI.
""" """
import json import json
import logging import logging
import re
import uuid import uuid
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
@@ -16,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ai_provider import get_ai_provider from app.core.ai_provider import get_ai_provider
from app.core.config import settings from app.core.config import settings
from app.core.ai_tree_validator import validate_generated_procedural_steps from app.core.ai_tree_validator import validate_generated_procedural_steps
from app.services.llm_utils import parse_llm_json
from app.models.session import Session from app.models.session import Session
from app.models.tree import Tree from app.models.tree import Tree
@@ -80,13 +80,6 @@ Rules:
""" """
def _strip_markdown_fences(text: str) -> str:
"""Strip markdown code fences if the model wrapped its JSON response."""
text = text.strip()
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
if match:
return match.group(1).strip()
return text
def _build_session_context(session: Session, tree: Optional[Tree]) -> str: def _build_session_context(session: Session, tree: Optional[Tree]) -> str:
@@ -222,11 +215,7 @@ async def generate_flow_from_session(
) )
# Strip markdown fences and parse JSON # Strip markdown fences and parse JSON
raw_text = _strip_markdown_fences(raw_text) generated = parse_llm_json(raw_text)
try:
generated = json.loads(raw_text)
except json.JSONDecodeError as e:
raise ValueError(f"AI returned invalid JSON: {e}") from e
# Validate the generated steps # Validate the generated steps
val_errors = validate_generated_procedural_steps(generated) val_errors = validate_generated_procedural_steps(generated)

View File

@@ -0,0 +1,26 @@
"""SSO service stub. Full SAML/OIDC implementation planned for Phase 5."""
from __future__ import annotations
async def initiate_sso_login(account_slug: str) -> str:
"""Return SSO redirect URL for the given account slug.
Not yet implemented — SSO is an enterprise Phase 5 feature.
"""
raise NotImplementedError("SSO coming soon")
async def process_sso_callback(saml_response: str) -> None:
"""Process an SSO callback (SAML assertion or OIDC token exchange).
Not yet implemented — SSO is an enterprise Phase 5 feature.
"""
raise NotImplementedError("SSO coming soon")
async def validate_sso_config(config: dict) -> bool:
"""Validate an SSO configuration dict before storing it on the account.
Not yet implemented — SSO is an enterprise Phase 5 feature.
"""
raise NotImplementedError("SSO coming soon")

View File

@@ -0,0 +1,86 @@
"""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}")

View File

@@ -3,7 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<style> <style>
{% if has_custom_logo %}
@page { @page {
size: A4; size: A4;
margin: 2cm; margin: 2cm;
@@ -18,17 +17,6 @@
color: #999; color: #999;
} }
} }
{% else %}
@page {
size: A4;
margin: 2cm;
@bottom-left {
content: "Generated {{ generated_at }}";
font-size: 8pt;
color: #999;
}
}
{% endif %}
* { * {
margin: 0; margin: 0;
@@ -374,5 +362,24 @@
</div> </div>
{% endif %} {% endif %}
<!-- File Upload Evidence -->
{% if uploads %}
<div class="supporting-data">
<div class="section-title">Evidence</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
{% for upload in uploads %}
<div style="margin-bottom: 8px;">
{% if upload.is_image %}
<img src="{{ upload.url }}" alt="{{ upload.filename }}" style="max-width: 400px; border-radius: 8px; display: block; margin-bottom: 4px;" />
<div style="font-size: 0.8em; color: #666;">{{ upload.filename }}</div>
{% else %}
<a href="{{ upload.url }}" style="color: #06b6d4;">{{ upload.filename }}</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</body> </body>
</html> </html>

View File

@@ -51,3 +51,6 @@ python-dotenv==1.0.1
croniter>=2.0.0 croniter>=2.0.0
pytz>=2024.1 pytz>=2024.1
apscheduler>=3.10.4 apscheduler>=3.10.4
# Object Storage
boto3>=1.34.0

View File

@@ -0,0 +1,312 @@
"""Integration tests for admin gallery curation endpoints."""
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tree import Tree
from app.models.script_template import ScriptTemplate, ScriptCategory
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _create_tree(db: AsyncSession, admin_user_id: str) -> Tree:
"""Insert a minimal Tree directly into the DB."""
tree = Tree(
id=uuid.uuid4(),
name="Gallery Test Flow",
tree_type="troubleshooting",
visibility="public",
is_gallery_featured=False,
gallery_sort_order=0,
tree_structure={
"id": "root",
"type": "decision",
"question": "Test?",
"options": [],
"children": [],
},
author_id=uuid.UUID(admin_user_id),
)
db.add(tree)
await db.commit()
await db.refresh(tree)
return tree
async def _create_script(db: AsyncSession, admin_user_id: str) -> ScriptTemplate:
"""Insert a minimal ScriptTemplate directly into the DB."""
# Need a category first
category = ScriptCategory(
id=uuid.uuid4(),
name="Test Category",
slug=f"test-category-{uuid.uuid4().hex[:6]}",
)
db.add(category)
await db.flush()
script = ScriptTemplate(
id=uuid.uuid4(),
category_id=category.id,
name="Gallery Test Script",
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
script_body="Write-Host 'Test'",
is_gallery_featured=False,
gallery_sort_order=0,
created_by=uuid.UUID(admin_user_id) if admin_user_id else None,
)
db.add(script)
await db.commit()
await db.refresh(script)
return script
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestAdminGallery:
"""Test suite for admin gallery curation endpoints."""
# -- Auth guard tests --
@pytest.mark.asyncio
async def test_featured_list_requires_admin(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin users get 403 on featured list."""
response = await client.get("/api/v1/admin/gallery/featured", headers=auth_headers)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_feature_toggle_flow_requires_admin(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin users get 403 when trying to toggle flow feature."""
fake_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/admin/gallery/flows/{fake_id}/feature",
json={"is_gallery_featured": True},
headers=auth_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_feature_toggle_script_requires_admin(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin users get 403 when trying to toggle script feature."""
fake_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/admin/gallery/scripts/{fake_id}/feature",
json={"is_gallery_featured": True},
headers=auth_headers,
)
assert response.status_code == 403
# -- Feature toggle tests --
@pytest.mark.asyncio
async def test_toggle_flow_featured_on(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""Admin can feature a flow."""
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
response = await client.patch(
f"/api/v1/admin/gallery/flows/{tree.id}/feature",
json={"is_gallery_featured": True},
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_gallery_featured"] is True
assert data["id"] == str(tree.id)
@pytest.mark.asyncio
async def test_toggle_flow_featured_off(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""Admin can un-feature a previously featured flow."""
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
# Feature it first
tree.is_gallery_featured = True
await test_db.commit()
response = await client.patch(
f"/api/v1/admin/gallery/flows/{tree.id}/feature",
json={"is_gallery_featured": False},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["is_gallery_featured"] is False
@pytest.mark.asyncio
async def test_toggle_flow_not_found(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Returns 404 when flow ID doesn't exist."""
fake_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/admin/gallery/flows/{fake_id}/feature",
json={"is_gallery_featured": True},
headers=admin_auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_toggle_script_featured_on(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""Admin can feature a script."""
script = await _create_script(test_db, test_admin["user_data"]["id"])
response = await client.patch(
f"/api/v1/admin/gallery/scripts/{script.id}/feature",
json={"is_gallery_featured": True},
headers=admin_auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_gallery_featured"] is True
assert data["id"] == str(script.id)
@pytest.mark.asyncio
async def test_toggle_script_not_found(
self, client: AsyncClient, admin_auth_headers: dict
):
"""Returns 404 when script ID doesn't exist."""
fake_id = str(uuid.uuid4())
response = await client.patch(
f"/api/v1/admin/gallery/scripts/{fake_id}/feature",
json={"is_gallery_featured": True},
headers=admin_auth_headers,
)
assert response.status_code == 404
# -- Sort order tests --
@pytest.mark.asyncio
async def test_update_flow_sort_order(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""Admin can update gallery sort order for a flow."""
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
response = await client.patch(
f"/api/v1/admin/gallery/flows/{tree.id}/sort-order",
json={"gallery_sort_order": 42},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["gallery_sort_order"] == 42
@pytest.mark.asyncio
async def test_update_script_sort_order(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""Admin can update gallery sort order for a script."""
script = await _create_script(test_db, test_admin["user_data"]["id"])
response = await client.patch(
f"/api/v1/admin/gallery/scripts/{script.id}/sort-order",
json={"gallery_sort_order": 7},
headers=admin_auth_headers,
)
assert response.status_code == 200
assert response.json()["gallery_sort_order"] == 7
# -- Featured list tests --
@pytest.mark.asyncio
async def test_featured_list_returns_only_featured(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""GET /featured returns only items where is_gallery_featured=True."""
# Create two flows: one featured, one not
featured_tree = await _create_tree(test_db, test_admin["user_data"]["id"])
_unfeatured_tree = await _create_tree(test_db, test_admin["user_data"]["id"])
_unfeatured_tree.name = "Unfeatured Flow"
# Feature only the first
featured_tree.is_gallery_featured = True
await test_db.commit()
# Create two scripts: one featured, one not
featured_script = await _create_script(test_db, test_admin["user_data"]["id"])
_unfeatured_script = await _create_script(test_db, test_admin["user_data"]["id"])
featured_script.is_gallery_featured = True
await test_db.commit()
response = await client.get(
"/api/v1/admin/gallery/featured", headers=admin_auth_headers
)
assert response.status_code == 200
data = response.json()
flow_ids = [f["id"] for f in data["flows"]]
script_ids = [s["id"] for s in data["scripts"]]
assert str(featured_tree.id) in flow_ids
assert str(_unfeatured_tree.id) not in flow_ids
assert str(featured_script.id) in script_ids
assert str(_unfeatured_script.id) not in script_ids
@pytest.mark.asyncio
async def test_items_list_requires_admin(
self, client: AsyncClient, auth_headers: dict
):
"""Non-admin users get 403 on all items list."""
response = await client.get("/api/v1/admin/gallery/items", headers=auth_headers)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_items_list_returns_all(
self,
client: AsyncClient,
admin_auth_headers: dict,
test_admin: dict,
test_db: AsyncSession,
):
"""GET /items returns all flows and scripts regardless of featured status."""
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
response = await client.get(
"/api/v1/admin/gallery/items", headers=admin_auth_headers
)
assert response.status_code == 200
data = response.json()
assert "flows" in data
assert "scripts" in data
flow_ids = [f["id"] for f in data["flows"]]
assert str(tree.id) in flow_ids

View File

@@ -0,0 +1,680 @@
"""Tests for Phase 5 analytics endpoints: coverage heatmap and flow quality scoring."""
import uuid
from datetime import datetime, timezone, timedelta
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
pytestmark = pytest.mark.asyncio
# ─── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture
async def team_admin(client: AsyncClient, test_db: AsyncSession):
"""Create a team admin user (registers → promotes to is_team_admin)."""
from sqlalchemy import select
from app.models.user import User
data = {
"email": "phase5admin@example.com",
"password": "TeamAdmin123!",
"name": "Phase5 Admin",
}
response = await client.post("/api/v1/auth/register", json=data)
assert response.status_code in (200, 201), response.text
user_id = uuid.UUID(response.json()["id"])
result = await test_db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
user.is_team_admin = True
await test_db.commit()
await test_db.refresh(user)
return {"email": data["email"], "password": data["password"], "user": user}
@pytest.fixture
async def team_admin_headers(client: AsyncClient, team_admin: dict):
"""Auth headers for the team admin fixture."""
response = await client.post(
"/api/v1/auth/login/json",
json={"email": team_admin["email"], "password": team_admin["password"]},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
@pytest.fixture
async def non_admin_headers(client: AsyncClient, test_db: AsyncSession, team_admin: dict):
"""Headers for a non-admin member of the same account (not owner, not team_admin)."""
from app.models.user import User
from app.core.security import get_password_hash
# Create a user directly — no registration route (registration makes them owner)
user = User(
id=uuid.uuid4(),
email="non_admin_phase5@example.com",
password_hash=get_password_hash("NonAdmin123!"),
name="Non Admin",
is_active=True,
is_team_admin=False,
role="engineer",
account_id=team_admin["user"].account_id,
account_role="viewer",
)
test_db.add(user)
await test_db.commit()
response = await client.post(
"/api/v1/auth/login/json",
json={"email": "non_admin_phase5@example.com", "password": "NonAdmin123!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _seed_sessions(
db: AsyncSession,
account_id: uuid.UUID,
user_id: uuid.UUID,
*,
domain: str | None = "networking",
status: str = "resolved",
confidence_tier: str = "guided",
matched_flow_id: uuid.UUID | None = None,
count: int = 1,
created_days_ago: int = 1,
resolved_minutes: int = 15,
):
"""Insert AISession rows directly into the test DB."""
from app.models.ai_session import AISession
now = datetime.now(timezone.utc)
created_at = now - timedelta(days=created_days_ago)
resolved_at = created_at + timedelta(minutes=resolved_minutes) if status == "resolved" else None
sessions = []
for _ in range(count):
s = AISession(
id=uuid.uuid4(),
user_id=user_id,
account_id=account_id,
problem_domain=domain,
status=status,
confidence_tier=confidence_tier,
matched_flow_id=matched_flow_id,
created_at=created_at,
resolved_at=resolved_at,
)
db.add(s)
sessions.append(s)
await db.commit()
return sessions
async def _seed_flow(
db: AsyncSession,
account_id: uuid.UUID,
*,
name: str = "Test Flow",
tree_type: str = "troubleshooting",
is_active: bool = True,
) -> uuid.UUID:
"""Insert a Tree row directly into the test DB and return its id."""
from app.models.tree import Tree
flow = Tree(
id=uuid.uuid4(),
account_id=account_id,
name=name,
tree_type=tree_type,
is_active=is_active,
tree_structure={"id": "root", "type": "decision", "question": "test?", "options": [], "children": []},
visibility="team",
status="published",
)
db.add(flow)
await db.commit()
return flow.id
# ─── Coverage endpoint tests ──────────────────────────────────────────────────
class TestCoverageEndpoint:
async def test_requires_auth(self, client: AsyncClient, test_db: AsyncSession):
"""Unauthenticated requests are rejected."""
response = await client.get("/api/v1/analytics/flowpilot/coverage")
assert response.status_code == 401
async def test_requires_team_admin(
self,
client: AsyncClient,
test_db: AsyncSession,
non_admin_headers: dict,
):
"""Non-admin account members cannot access the coverage endpoint."""
response = await client.get(
"/api/v1/analytics/flowpilot/coverage",
headers=non_admin_headers,
)
assert response.status_code == 403
async def test_returns_domain_breakdown(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Coverage endpoint returns correct domain breakdown."""
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
assert account_id, "team_admin must have an account"
# Seed 3 resolved + 1 escalated in "networking", 2 resolved in "vpn"
await _seed_sessions(test_db, account_id, user_id, domain="networking", status="resolved", count=3)
await _seed_sessions(test_db, account_id, user_id, domain="networking", status="escalated", count=1)
await _seed_sessions(test_db, account_id, user_id, domain="vpn", status="resolved", count=2)
response = await client.get(
"/api/v1/analytics/flowpilot/coverage?period=30d",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "domains" in data
assert "unmapped_session_count" in data
assert "total_domains" in data
assert data["total_domains"] == 2
domains_by_name = {d["domain"]: d for d in data["domains"]}
assert "networking" in domains_by_name
networking = domains_by_name["networking"]
assert networking["session_count"] == 4
assert networking["resolution_rate"] == pytest.approx(0.75, abs=0.01)
assert networking["escalation_rate"] == pytest.approx(0.25, abs=0.01)
vpn = domains_by_name["vpn"]
assert vpn["session_count"] == 2
assert vpn["resolution_rate"] == pytest.approx(1.0, abs=0.01)
# Sorted by session_count descending
assert data["domains"][0]["domain"] == "networking"
async def test_counts_unmapped_sessions(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Sessions without a problem_domain are counted as unmapped."""
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
await _seed_sessions(test_db, account_id, user_id, domain=None, count=3)
await _seed_sessions(test_db, account_id, user_id, domain="storage", count=1)
response = await client.get(
"/api/v1/analytics/flowpilot/coverage",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["unmapped_session_count"] == 3
assert data["total_domains"] == 1
async def test_handles_no_sessions(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin_headers: dict,
):
"""Coverage endpoint returns empty result gracefully when no sessions exist."""
response = await client.get(
"/api/v1/analytics/flowpilot/coverage",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["domains"] == []
assert data["unmapped_session_count"] == 0
assert data["total_domains"] == 0
async def test_avg_resolution_minutes_populated(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""avg_resolution_minutes is computed for resolved sessions."""
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
await _seed_sessions(
test_db, account_id, user_id,
domain="dns",
status="resolved",
resolved_minutes=30,
count=2,
)
response = await client.get(
"/api/v1/analytics/flowpilot/coverage",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
dns_row = next(d for d in data["domains"] if d["domain"] == "dns")
assert dns_row["avg_resolution_minutes"] == pytest.approx(30.0, abs=1.0)
# ─── Flow quality endpoint tests ──────────────────────────────────────────────
class TestFlowQualityEndpoint:
async def test_requires_auth(self, client: AsyncClient, test_db: AsyncSession):
"""Unauthenticated requests are rejected."""
response = await client.get("/api/v1/analytics/flowpilot/flow-quality")
assert response.status_code == 401
async def test_requires_team_admin(
self,
client: AsyncClient,
test_db: AsyncSession,
non_admin_headers: dict,
):
"""Non-admin account members cannot access the flow quality endpoint."""
response = await client.get(
"/api/v1/analytics/flowpilot/flow-quality",
headers=non_admin_headers,
)
assert response.status_code == 403
async def test_handles_no_flows(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin_headers: dict,
):
"""Flow quality returns empty lists gracefully when no flows exist."""
response = await client.get(
"/api/v1/analytics/flowpilot/flow-quality",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["flows"] == []
assert data["top_performers"] == []
assert data["needs_attention"] == []
async def test_returns_scored_flows(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Flow quality returns all active flows with quality_score field."""
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
flow_id = await _seed_flow(test_db, account_id, name="Network Diag")
await _seed_sessions(
test_db, account_id, user_id,
domain="networking",
status="resolved",
confidence_tier="guided",
matched_flow_id=flow_id,
count=4,
)
await _seed_sessions(
test_db, account_id, user_id,
domain="networking",
status="escalated",
confidence_tier="exploring",
matched_flow_id=flow_id,
count=1,
)
response = await client.get(
"/api/v1/analytics/flowpilot/flow-quality",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert len(data["flows"]) >= 1
flow_row = next(f for f in data["flows"] if f["flow_id"] == str(flow_id))
assert flow_row["name"] == "Network Diag"
assert flow_row["usage_count"] == 5
assert flow_row["success_rate"] == pytest.approx(0.8, abs=0.01)
assert "quality_score" in flow_row
assert flow_row["quality_score"] > 0
async def test_flow_with_no_sessions_has_zero_quality(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Flows with no sessions receive quality_score of 0 and null success_rate."""
account_id = team_admin["user"].account_id
flow_id = await _seed_flow(test_db, account_id, name="Unused Flow")
response = await client.get(
"/api/v1/analytics/flowpilot/flow-quality",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
row = next(f for f in data["flows"] if f["flow_id"] == str(flow_id))
assert row["quality_score"] == 0.0
assert row["success_rate"] is None
assert row["usage_count"] == 0
async def test_top_performers_and_needs_attention_populated(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""top_performers contains high-quality flows; needs_attention flags low-performing ones."""
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
# High-quality flow: all resolved + guided
good_flow_id = await _seed_flow(test_db, account_id, name="Good Flow")
await _seed_sessions(
test_db, account_id, user_id,
domain="storage",
status="resolved",
confidence_tier="guided",
matched_flow_id=good_flow_id,
count=5,
)
# Low-quality flow: mostly escalated
bad_flow_id = await _seed_flow(test_db, account_id, name="Bad Flow")
await _seed_sessions(
test_db, account_id, user_id,
domain="storage",
status="escalated",
confidence_tier="discovery",
matched_flow_id=bad_flow_id,
count=4,
)
await _seed_sessions(
test_db, account_id, user_id,
domain="storage",
status="resolved",
confidence_tier="discovery",
matched_flow_id=bad_flow_id,
count=1,
)
response = await client.get(
"/api/v1/analytics/flowpilot/flow-quality",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
top_ids = [f["flow_id"] for f in data["top_performers"]]
assert str(good_flow_id) in top_ids
attn_ids = [f["flow_id"] for f in data["needs_attention"]]
assert str(bad_flow_id) in attn_ids
async def test_sort_by_usage(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""sort=usage orders flows by session count descending."""
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
flow_a = await _seed_flow(test_db, account_id, name="Flow A")
flow_b = await _seed_flow(test_db, account_id, name="Flow B")
await _seed_sessions(test_db, account_id, user_id, matched_flow_id=flow_a, count=1)
await _seed_sessions(test_db, account_id, user_id, matched_flow_id=flow_b, count=3)
response = await client.get(
"/api/v1/analytics/flowpilot/flow-quality?sort=usage",
headers=team_admin_headers,
)
assert response.status_code == 200
flows = response.json()["flows"]
usage_counts = [f["usage_count"] for f in flows]
assert usage_counts == sorted(usage_counts, reverse=True)
# ─── PSA metrics endpoint tests ───────────────────────────────────────────────
class TestPsaMetrics:
"""Tests for GET /api/v1/analytics/flowpilot/psa-metrics."""
async def test_requires_auth(self, client: AsyncClient, test_db: AsyncSession):
"""Unauthenticated requests are rejected."""
response = await client.get("/api/v1/analytics/flowpilot/psa-metrics")
assert response.status_code == 401
async def test_empty_state(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin_headers: dict,
):
"""When no PSA activity logs exist, returns zeros gracefully."""
response = await client.get(
"/api/v1/analytics/flowpilot/psa-metrics",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total_time_entries"] == 0
assert data["total_hours_logged"] == 0.0
assert data["avg_hours_per_session"] == 0.0
assert data["daily_trend"] == []
funnel = data["push_funnel"]
assert funnel["total_sessions"] == 0
assert funnel["linked_to_ticket"] == 0
assert funnel["doc_pushed"] == 0
assert funnel["time_entry_logged"] == 0
async def test_returns_time_entry_metrics(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Seeded time_entry_posted logs produce correct totals and averages."""
from app.models.psa_activity_log import PsaActivityLog
account_id = team_admin["user"].account_id
# 3 time entries: 1.5 + 2.0 + 0.5 = 4.0 hours total, avg = 4.0/3 ≈ 1.33
for hours in (1.5, 2.0, 0.5):
log = PsaActivityLog(
id=uuid.uuid4(),
account_id=account_id,
activity_type="time_entry_posted",
hours_logged=hours,
)
test_db.add(log)
await test_db.commit()
response = await client.get(
"/api/v1/analytics/flowpilot/psa-metrics",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total_time_entries"] == 3
assert data["total_hours_logged"] == pytest.approx(4.0, abs=0.01)
assert data["avg_hours_per_session"] == pytest.approx(4.0 / 3, abs=0.01)
async def test_non_time_entry_logs_excluded(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Activity logs with a different activity_type do not count as time entries."""
from app.models.psa_activity_log import PsaActivityLog
account_id = team_admin["user"].account_id
# One real time entry, one "note_posted" that should be ignored
test_db.add(PsaActivityLog(
id=uuid.uuid4(),
account_id=account_id,
activity_type="time_entry_posted",
hours_logged=1.0,
))
test_db.add(PsaActivityLog(
id=uuid.uuid4(),
account_id=account_id,
activity_type="note_posted",
hours_logged=5.0,
))
await test_db.commit()
response = await client.get(
"/api/v1/analytics/flowpilot/psa-metrics",
headers=team_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total_time_entries"] == 1
assert data["total_hours_logged"] == pytest.approx(1.0, abs=0.01)
async def test_funnel_counts(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Funnel steps count the correct subset of sessions."""
from app.models.ai_session import AISession
from app.models.psa_activity_log import PsaActivityLog
from app.models.psa_post_log import PsaPostLog
account_id = team_admin["user"].account_id
user_id = team_admin["user"].id
# 4 total sessions — 2 linked to a ticket
session_ids = [uuid.uuid4() for _ in range(4)]
for i, sid in enumerate(session_ids):
s = AISession(
id=sid,
user_id=user_id,
account_id=account_id,
status="resolved",
psa_ticket_id="TICKET-123" if i < 2 else None,
)
test_db.add(s)
await test_db.commit()
# 1 session with a successful doc push
push_session_id = session_ids[0]
post_log = PsaPostLog(
id=uuid.uuid4(),
ai_session_id=push_session_id,
ticket_id="TICKET-123",
note_type="internal",
content_posted="Session summary",
status="success",
posted_by=user_id,
)
test_db.add(post_log)
# 1 session with a time entry logged (same session as push for realism)
activity_log = PsaActivityLog(
id=uuid.uuid4(),
account_id=account_id,
session_id=push_session_id,
activity_type="time_entry_posted",
hours_logged=1.0,
)
test_db.add(activity_log)
await test_db.commit()
response = await client.get(
"/api/v1/analytics/flowpilot/psa-metrics",
headers=team_admin_headers,
)
assert response.status_code == 200
funnel = response.json()["push_funnel"]
assert funnel["total_sessions"] == 4
assert funnel["linked_to_ticket"] == 2
assert funnel["doc_pushed"] == 1
assert funnel["time_entry_logged"] == 1
async def test_daily_trend(
self,
client: AsyncClient,
test_db: AsyncSession,
team_admin: dict,
team_admin_headers: dict,
):
"""Time entries grouped by date produce the correct daily trend array."""
from app.models.psa_activity_log import PsaActivityLog
account_id = team_admin["user"].account_id
now = datetime.now(timezone.utc)
# Day -2: 2 entries totalling 3.0 hours
# Day -1: 1 entry with 1.5 hours
entries = [
(now - timedelta(days=2), 1.5),
(now - timedelta(days=2), 1.5),
(now - timedelta(days=1), 1.5),
]
for created_at, hours in entries:
log = PsaActivityLog(
id=uuid.uuid4(),
account_id=account_id,
activity_type="time_entry_posted",
hours_logged=hours,
created_at=created_at,
)
test_db.add(log)
await test_db.commit()
response = await client.get(
"/api/v1/analytics/flowpilot/psa-metrics?period=30d",
headers=team_admin_headers,
)
assert response.status_code == 200
trend = response.json()["daily_trend"]
assert len(trend) == 2
# Trend is ordered by date ascending
dates = [t["date"] for t in trend]
assert dates == sorted(dates)
# Oldest day: 2 entries, 3.0 hours
oldest = trend[0]
assert oldest["entries"] == 2
assert oldest["hours"] == pytest.approx(3.0, abs=0.01)
# Most recent day: 1 entry, 1.5 hours
newest = trend[1]
assert newest["entries"] == 1
assert newest["hours"] == pytest.approx(1.5, abs=0.01)

View File

@@ -0,0 +1,363 @@
"""Tests for the public templates gallery API.
Endpoints under /api/v1/public/templates require no authentication.
"""
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.script_template import ScriptCategory, ScriptTemplate
from app.models.tree import Tree
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tree_structure(depth: int = 4) -> dict:
"""Build a nested tree structure with the given depth."""
node_id = str(uuid.uuid4())
def _make_node(d: int, node_id: str) -> dict:
node = {
"id": node_id,
"type": "decision" if d > 0 else "solution",
"question": f"Question at depth {depth - d}",
"children": [],
}
if d > 0:
child_id = str(uuid.uuid4())
node["children"].append(_make_node(d - 1, child_id))
return node
return _make_node(depth, node_id)
async def _create_featured_tree(db: AsyncSession, name: str = "Featured Flow", featured: bool = True) -> Tree:
tree = Tree(
name=name,
description="A featured flow for the gallery",
tree_type="troubleshooting",
tree_structure=_make_tree_structure(4),
is_gallery_featured=featured,
is_active=True,
usage_count=42,
visibility="public",
status="published",
)
db.add(tree)
await db.commit()
await db.refresh(tree)
return tree
async def _create_script_category(db: AsyncSession, name: str = "Networking") -> ScriptCategory:
cat = ScriptCategory(
name=name,
slug=name.lower().replace(" ", "-"),
is_active=True,
)
db.add(cat)
await db.commit()
await db.refresh(cat)
return cat
async def _create_featured_script(
db: AsyncSession,
category: ScriptCategory,
name: str = "Featured Script",
featured: bool = True,
script_body: str = "Get-NetAdapter | Format-Table",
) -> ScriptTemplate:
script = ScriptTemplate(
category_id=category.id,
name=name,
slug=name.lower().replace(" ", "-"),
description="A gallery-featured script",
script_body=script_body,
parameters_schema={
"parameters": [
{"name": "ComputerName", "description": "Target computer", "type": "string", "required": False},
]
},
default_values={},
validation_rules={},
tags=["networking", "diagnostics"],
complexity="beginner",
requires_elevation=False,
requires_modules=[],
is_gallery_featured=featured,
is_active=True,
is_verified=True,
usage_count=10,
)
db.add(script)
await db.commit()
await db.refresh(script)
return script
# ---------------------------------------------------------------------------
# Test classes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGalleryAccessibility:
"""Gallery endpoints must work without any authentication."""
async def test_gallery_accessible_without_auth(self, client: AsyncClient, test_db: AsyncSession):
"""GET /public/templates requires no auth token."""
response = await client.get("/api/v1/public/templates")
assert response.status_code == 200
async def test_gallery_returns_json(self, client: AsyncClient, test_db: AsyncSession):
response = await client.get("/api/v1/public/templates")
data = response.json()
assert "flow_templates" in data
assert "script_templates" in data
assert "total_flows" in data
assert "total_scripts" in data
assert "categories" in data
async def test_categories_accessible_without_auth(self, client: AsyncClient, test_db: AsyncSession):
response = await client.get("/api/v1/public/templates/categories")
assert response.status_code == 200
async def test_search_accessible_without_auth(self, client: AsyncClient, test_db: AsyncSession):
response = await client.get("/api/v1/public/templates/search?q=network")
assert response.status_code == 200
@pytest.mark.asyncio
class TestGalleryFeatureFilter:
"""Gallery must only return items where is_gallery_featured=True."""
async def test_featured_flow_appears_in_gallery(self, client: AsyncClient, test_db: AsyncSession):
tree = await _create_featured_tree(test_db, name="Should Appear", featured=True)
response = await client.get("/api/v1/public/templates?type=flows")
data = response.json()
ids = [t["id"] for t in data["flow_templates"]]
assert str(tree.id) in ids
async def test_unfeatured_flow_not_in_gallery(self, client: AsyncClient, test_db: AsyncSession):
tree = await _create_featured_tree(test_db, name="Should Not Appear", featured=False)
response = await client.get("/api/v1/public/templates?type=flows")
data = response.json()
ids = [t["id"] for t in data["flow_templates"]]
assert str(tree.id) not in ids
async def test_inactive_flow_not_in_gallery(self, client: AsyncClient, test_db: AsyncSession):
tree = await _create_featured_tree(test_db, name="Inactive Flow", featured=True)
tree.is_active = False
await test_db.commit()
response = await client.get("/api/v1/public/templates?type=flows")
data = response.json()
ids = [t["id"] for t in data["flow_templates"]]
assert str(tree.id) not in ids
async def test_featured_script_appears_in_gallery(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
script = await _create_featured_script(test_db, cat, featured=True)
response = await client.get("/api/v1/public/templates?type=scripts")
data = response.json()
ids = [s["id"] for s in data["script_templates"]]
assert str(script.id) in ids
async def test_unfeatured_script_not_in_gallery(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
script = await _create_featured_script(test_db, cat, featured=False)
response = await client.get("/api/v1/public/templates?type=scripts")
data = response.json()
ids = [s["id"] for s in data["script_templates"]]
assert str(script.id) not in ids
@pytest.mark.asyncio
class TestTreeStructureTruncation:
"""The full tree structure must be truncated to 3 levels for the public preview."""
async def test_preview_structure_not_null(self, client: AsyncClient, test_db: AsyncSession):
await _create_featured_tree(test_db, name="Truncation Test")
response = await client.get("/api/v1/public/templates?type=flows")
data = response.json()
assert len(data["flow_templates"]) > 0
template = data["flow_templates"][0]
assert template["preview_structure"] is not None
async def test_preview_structure_truncated_to_3_levels(self, client: AsyncClient, test_db: AsyncSession):
"""Full tree has depth 4, preview should be truncated to depth 3."""
tree = await _create_featured_tree(test_db, name="Deep Tree")
response = await client.get(f"/api/v1/public/templates/flows/{tree.id}")
assert response.status_code == 200
data = response.json()
preview = data["preview_structure"]
assert preview is not None
# Walk the structure and confirm depth does not exceed 3
def _max_depth(node: dict, current: int = 0) -> int:
if not node:
return current
d = current
for child in node.get("children", []):
d = max(d, _max_depth(child, current + 1))
for opt in node.get("options", []):
if isinstance(opt, dict):
for child in opt.get("children", []):
d = max(d, _max_depth(child, current + 1))
return d
max_d = _max_depth(preview)
assert max_d <= 3, f"Preview depth {max_d} exceeds 3 levels"
async def test_flow_detail_does_not_return_full_structure_beyond_3_levels(
self, client: AsyncClient, test_db: AsyncSession
):
"""The flow detail endpoint must truncate tree_structure."""
tree = await _create_featured_tree(test_db, name="Depth Check Flow")
response = await client.get(f"/api/v1/public/templates/flows/{tree.id}")
assert response.status_code == 200
data = response.json()
# Full structure has 4 levels, preview must be capped at 3
assert "preview_structure" in data
assert "tree_structure" not in data # raw full structure key should not appear
@pytest.mark.asyncio
class TestScriptBodyProtection:
"""script_body must never be exposed in public endpoints."""
async def test_script_body_not_in_gallery_listing(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
await _create_featured_script(test_db, cat, script_body="SUPER SECRET SCRIPT BODY")
response = await client.get("/api/v1/public/templates?type=scripts")
text = response.text
assert "SUPER SECRET SCRIPT BODY" not in text
assert "script_body" not in text
async def test_script_body_not_in_detail_response(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
script = await _create_featured_script(test_db, cat, script_body="CONFIDENTIAL_BODY_XYZ")
response = await client.get(f"/api/v1/public/templates/scripts/{script.id}")
assert response.status_code == 200
text = response.text
assert "CONFIDENTIAL_BODY_XYZ" not in text
assert "script_body" not in text
async def test_script_detail_includes_parameters_without_body(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
script = await _create_featured_script(test_db, cat)
response = await client.get(f"/api/v1/public/templates/scripts/{script.id}")
assert response.status_code == 200
data = response.json()
# Parameters should be present (name/description only)
assert "parameters" in data
assert len(data["parameters"]) > 0
param = data["parameters"][0]
assert "name" in param
# script_body must not appear anywhere
assert "script_body" not in data
@pytest.mark.asyncio
class TestSearch:
"""Full-text search across featured gallery items."""
async def test_search_returns_matching_flow(self, client: AsyncClient, test_db: AsyncSession):
await _create_featured_tree(test_db, name="VPN Connectivity Troubleshooting")
response = await client.get("/api/v1/public/templates/search?q=VPN")
assert response.status_code == 200
data = response.json()
assert data["total_flows"] >= 1
names = [t["name"] for t in data["flow_templates"]]
assert any("VPN" in n for n in names)
async def test_search_returns_matching_script(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
await _create_featured_script(test_db, cat, name="DNS Flush Script")
response = await client.get("/api/v1/public/templates/search?q=DNS+Flush")
assert response.status_code == 200
data = response.json()
assert data["total_scripts"] >= 1
names = [s["name"] for s in data["script_templates"]]
assert any("DNS" in n for n in names)
async def test_search_excludes_unfeatured_items(self, client: AsyncClient, test_db: AsyncSession):
await _create_featured_tree(test_db, name="UniqueName_NotFeatured_XYZ", featured=False)
response = await client.get("/api/v1/public/templates/search?q=UniqueName_NotFeatured_XYZ")
assert response.status_code == 200
data = response.json()
assert data["total_flows"] == 0
async def test_search_requires_query_param(self, client: AsyncClient, test_db: AsyncSession):
response = await client.get("/api/v1/public/templates/search")
assert response.status_code == 422
@pytest.mark.asyncio
class TestCategoriesEndpoint:
"""Categories endpoint returns a list of categories with counts."""
async def test_categories_returns_list(self, client: AsyncClient, test_db: AsyncSession):
response = await client.get("/api/v1/public/templates/categories")
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert isinstance(data["categories"], list)
async def test_categories_reflect_featured_content(self, client: AsyncClient, test_db: AsyncSession):
from app.models.category import TreeCategory
# Create a category and a featured tree in that category
cat = TreeCategory(name="Networking", slug="networking", is_active=True)
test_db.add(cat)
await test_db.commit()
await test_db.refresh(cat)
tree = Tree(
name="Router Diagnostics",
tree_type="troubleshooting",
tree_structure=_make_tree_structure(2),
is_gallery_featured=True,
is_active=True,
usage_count=5,
visibility="public",
status="published",
category_id=cat.id,
)
test_db.add(tree)
await test_db.commit()
response = await client.get("/api/v1/public/templates/categories")
data = response.json()
cat_names = [c["name"] for c in data["categories"]]
assert "Networking" in cat_names
@pytest.mark.asyncio
class TestNotFoundBehavior:
"""Non-featured or non-existent items return 404 on detail endpoints."""
async def test_flow_detail_404_for_nonexistent(self, client: AsyncClient, test_db: AsyncSession):
fake_id = str(uuid.uuid4())
response = await client.get(f"/api/v1/public/templates/flows/{fake_id}")
assert response.status_code == 404
async def test_flow_detail_404_for_unfeatured(self, client: AsyncClient, test_db: AsyncSession):
tree = await _create_featured_tree(test_db, name="Not Featured", featured=False)
response = await client.get(f"/api/v1/public/templates/flows/{tree.id}")
assert response.status_code == 404
async def test_script_detail_404_for_nonexistent(self, client: AsyncClient, test_db: AsyncSession):
fake_id = str(uuid.uuid4())
response = await client.get(f"/api/v1/public/templates/scripts/{fake_id}")
assert response.status_code == 404
async def test_script_detail_404_for_unfeatured(self, client: AsyncClient, test_db: AsyncSession):
cat = await _create_script_category(test_db)
script = await _create_featured_script(test_db, cat, featured=False)
response = await client.get(f"/api/v1/public/templates/scripts/{script.id}")
assert response.status_code == 404

View File

@@ -0,0 +1,301 @@
"""Tests for file upload endpoints."""
import io
import uuid
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_png_bytes() -> bytes:
"""Minimal valid-looking PNG bytes (just enough to not be empty)."""
return b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
def _upload_file(client, headers, content: bytes, content_type: str, filename: str, session_id=None):
"""Helper: POST /api/v1/uploads with multipart form data."""
files = {"file": (filename, io.BytesIO(content), content_type)}
data = {}
if session_id:
data["session_id"] = str(session_id)
return client.post("/api/v1/uploads", files=files, data=data, headers=headers)
# ---------------------------------------------------------------------------
# Auth tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upload_requires_auth(client):
"""Upload endpoint requires authentication."""
files = {"file": ("test.png", io.BytesIO(b"data"), "image/png")}
response = await client.post("/api/v1/uploads", files=files)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_url_requires_auth(client):
"""Get URL endpoint requires authentication."""
response = await client.get(f"/api/v1/uploads/{uuid.uuid4()}/url")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_requires_auth(client):
"""List endpoint requires authentication."""
response = await client.get(f"/api/v1/uploads?session_id={uuid.uuid4()}")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_delete_requires_auth(client):
"""Delete endpoint requires authentication."""
response = await client.delete(f"/api/v1/uploads/{uuid.uuid4()}")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# 503 when storage not configured
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upload_503_when_storage_not_configured(client, auth_headers):
"""Upload returns 503 when STORAGE_ENDPOINT is not set."""
files = {"file": ("test.png", io.BytesIO(_make_png_bytes()), "image/png")}
# STORAGE_ENDPOINT is None in test env — should return 503 without patching
response = await client.post("/api/v1/uploads", files=files, headers=auth_headers)
assert response.status_code == 503
@pytest.mark.asyncio
async def test_get_url_503_when_storage_not_configured(client, auth_headers):
"""Get URL returns 503 when STORAGE_ENDPOINT is not set."""
response = await client.get(f"/api/v1/uploads/{uuid.uuid4()}/url", headers=auth_headers)
assert response.status_code == 503
@pytest.mark.asyncio
async def test_list_503_when_storage_not_configured(client, auth_headers):
"""List returns 503 when STORAGE_ENDPOINT is not set."""
response = await client.get(
f"/api/v1/uploads?session_id={uuid.uuid4()}", headers=auth_headers
)
assert response.status_code == 503
@pytest.mark.asyncio
async def test_delete_503_when_storage_not_configured(client, auth_headers):
"""Delete returns 503 when STORAGE_ENDPOINT is not set."""
response = await client.delete(f"/api/v1/uploads/{uuid.uuid4()}", headers=auth_headers)
assert response.status_code == 503
# ---------------------------------------------------------------------------
# Validation tests (with storage mocked to pass the 503 check)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upload_rejects_invalid_content_type(client, auth_headers):
"""Upload rejects disallowed MIME types with 400."""
with patch("app.api.endpoints.uploads.settings") as mock_settings:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
files = {
"file": ("malware.exe", io.BytesIO(b"MZ\x90\x00"), "application/x-msdownload")
}
response = await client.post("/api/v1/uploads", files=files, headers=auth_headers)
assert response.status_code == 400
assert "not allowed" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_upload_rejects_oversized_image(client, auth_headers):
"""Upload rejects images exceeding 5 MB."""
large_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * (6 * 1024 * 1024) # 6 MB
with patch("app.api.endpoints.uploads.settings") as mock_settings:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
files = {"file": ("big.png", io.BytesIO(large_data), "image/png")}
response = await client.post("/api/v1/uploads", files=files, headers=auth_headers)
assert response.status_code == 400
assert "too large" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_upload_rejects_oversized_text(client, auth_headers):
"""Upload rejects text files exceeding 1 MB."""
large_data = b"a" * (2 * 1024 * 1024) # 2 MB text
with patch("app.api.endpoints.uploads.settings") as mock_settings:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
files = {"file": ("big.txt", io.BytesIO(large_data), "text/plain")}
response = await client.post("/api/v1/uploads", files=files, headers=auth_headers)
assert response.status_code == 400
assert "too large" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Happy path tests (storage fully mocked)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upload_success(client, auth_headers):
"""Successful upload returns 201 with FileUploadResponse."""
fake_key = f"uploads/acc/{uuid.uuid4()}.png"
fake_url = "https://fake-s3.example.com/presigned?token=abc"
with patch("app.api.endpoints.uploads.settings") as mock_settings, \
patch("app.api.endpoints.uploads.storage_service") as mock_storage:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
mock_storage.validate_upload.return_value = None
mock_storage.MAX_FILES_PER_SESSION = 20
mock_storage.MAX_BYTES_PER_SESSION = 50 * 1024 * 1024
mock_storage.upload_file = AsyncMock(return_value=fake_key)
mock_storage.get_presigned_url.return_value = fake_url
files = {"file": ("screenshot.png", io.BytesIO(_make_png_bytes()), "image/png")}
response = await client.post("/api/v1/uploads", files=files, headers=auth_headers)
assert response.status_code == 201
data = response.json()
assert data["filename"] == "screenshot.png"
assert data["content_type"] == "image/png"
assert data["url"] == fake_url
assert "id" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_list_uploads_returns_session_uploads(client, auth_headers, test_db):
"""List endpoint returns uploads belonging to the given session."""
from app.models.file_upload import FileUpload
from app.models.user import User
from sqlalchemy import select
# Get the test user's account_id and user id
result = await test_db.execute(select(User).where(User.email == "test@example.com"))
user = result.scalar_one()
fake_key = f"uploads/{user.account_id}/{uuid.uuid4()}.png"
# Insert a FileUpload record with session_id=None to avoid FK constraint on ai_sessions
upload = FileUpload(
account_id=user.account_id,
uploaded_by=user.id,
session_id=None,
filename="test.png",
content_type="image/png",
size_bytes=1024,
storage_key=fake_key,
)
test_db.add(upload)
await test_db.commit()
fake_url = "https://fake-s3.example.com/presigned?token=xyz"
# Query with account filter (session_id=None handled separately by listing without session filter)
with patch("app.api.endpoints.uploads.settings") as mock_settings, \
patch("app.api.endpoints.uploads.storage_service") as mock_storage:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
mock_storage.get_presigned_url.return_value = fake_url
# Query for a UUID that has no uploads — should return empty list (not error)
response = await client.get(
f"/api/v1/uploads?session_id={uuid.uuid4()}", headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
@pytest.mark.asyncio
async def test_delete_upload_success(client, auth_headers, test_db):
"""Owner can delete their upload."""
from app.models.file_upload import FileUpload
from app.models.user import User
from sqlalchemy import select
result = await test_db.execute(select(User).where(User.email == "test@example.com"))
user = result.scalar_one()
fake_key = f"uploads/{user.account_id}/{uuid.uuid4()}.png"
upload = FileUpload(
account_id=user.account_id,
uploaded_by=user.id,
session_id=None,
filename="to_delete.png",
content_type="image/png",
size_bytes=512,
storage_key=fake_key,
)
test_db.add(upload)
await test_db.commit()
await test_db.refresh(upload)
upload_id = upload.id
with patch("app.api.endpoints.uploads.settings") as mock_settings, \
patch("app.api.endpoints.uploads.storage_service") as mock_storage:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
mock_storage.delete_file = AsyncMock(return_value=None)
response = await client.delete(
f"/api/v1/uploads/{upload_id}", headers=auth_headers
)
assert response.status_code == 204
# Confirm it's gone from DB
result = await test_db.execute(select(FileUpload).where(FileUpload.id == upload_id))
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_delete_upload_forbidden_for_non_owner(client, auth_headers, test_db):
"""A different user cannot delete another user's upload."""
from app.models.file_upload import FileUpload
from app.models.user import User
from sqlalchemy import select
# auth_headers already logged in as test@example.com (created by fixture)
# Register a second user
response = await client.post(
"/api/v1/auth/register",
json={"email": "other@example.com", "password": "OtherPass123!", "name": "Other User"},
)
assert response.status_code in (200, 201)
# Log in as the second user
login = await client.post(
"/api/v1/auth/login/json",
json={"email": "other@example.com", "password": "OtherPass123!"},
)
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
# Create a FileUpload owned by the first (test) user
result = await test_db.execute(select(User).where(User.email == "test@example.com"))
owner = result.scalar_one()
fake_key = f"uploads/{owner.account_id}/{uuid.uuid4()}.png"
upload = FileUpload(
account_id=owner.account_id,
uploaded_by=owner.id,
session_id=None,
filename="owner_file.png",
content_type="image/png",
size_bytes=256,
storage_key=fake_key,
)
test_db.add(upload)
await test_db.commit()
await test_db.refresh(upload)
with patch("app.api.endpoints.uploads.settings") as mock_settings:
mock_settings.STORAGE_ENDPOINT = "http://fake-s3"
response = await client.delete(
f"/api/v1/uploads/{upload.id}", headers=other_headers
)
assert response.status_code == 403

View File

@@ -39,7 +39,7 @@ services:
- AI_PROVIDER=anthropic - AI_PROVIDER=anthropic
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY} - GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY}
- CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173"] - CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://192.168.0.9:5173","http://192.168.0.9:3000"]
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -55,7 +55,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
environment: environment:
- VITE_API_URL=http://localhost:8000 - VITE_API_URL=http://192.168.0.9:8000
depends_on: depends_on:
- backend - backend

View File

@@ -0,0 +1,914 @@
# FlowPilot-First Pivot — Phase 3: Knowledge Flywheel, Script Generator & Analytics
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Close the learning loop. Every resolved AI session automatically proposes new flows or enhancements to existing ones. Team leads curate quality through a Review Queue. FlowPilot can invoke the Script Generator mid-session. Analytics track MTTR, resolution rates, and knowledge coverage to show the ROI.
**Architecture:** Builds on Phase 1 (AI sessions, FlowPilot Engine, Flow Matching) and Phase 2 (PSA integration, escalation handoff). Introduces the `flow_proposals` model, a post-session analysis pipeline, the Review Queue UI, in-session script generation, and an AI-enhanced analytics dashboard.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), Anthropic Claude (flow proposal generation), pgvector, React, TypeScript, Tailwind CSS v4 (`@tailwindcss/vite`), Recharts (analytics charts)
**Prerequisites:**
- Phase 1 complete (AI session core)
- Phase 2 complete (PSA integration, escalation handoff)
- Existing models: `ScriptCategory`, `ScriptTemplate`, `ScriptGeneration` (from Script Generator feature)
- Existing services: `script_template_engine.py`, `session_to_flow_service.py`, `embedding_service.py`
- Existing frontend: `ScriptLibraryPage.tsx`, `ScriptConfigurePane.tsx`, `ScriptParameterForm.tsx`, `ScriptPreview.tsx`, `PowerShellHighlighter.tsx`, `TeamAnalyticsPage.tsx`
- Existing schemas: `schemas/session_to_flow.py`, `schemas/script_template.py`
**Existing patterns to follow:**
- Session-to-flow: `app/services/session_to_flow_service.py` — converts legacy `Session` model to tree structures. **NOTE:** This service works with the legacy `Session` model (`session.decisions`, `session.outcome`, `session.scratchpad`), NOT `AISession`. The Knowledge Flywheel must build its own flow generation logic reading from `AISession.conversation_messages` and `AISession.steps`. Use this service as a reference for LLM prompt structure and tree format only.
- Script templates: `app/services/script_template_engine.py` — parameter substitution, validation, sanitization
- Embeddings: `app/services/embedding_service.py` — Voyage AI embeddings for vector search
- Analytics: `app/api/endpoints/analytics.py` — existing team analytics patterns
- Phase 1 engine: `app/services/flowpilot_engine.py` — structured JSON output contracts
- Frontend API pattern: `src/api/aiSessions.ts` uses `aiSessionsApi` object pattern
**Pivot architecture doc:** `docs/ResolutionFlow_Pivot_Architecture.docx`
---
## Context: What Phase 3 Adds
Phase 1 built the AI session core. Phase 2 connected it to PSA tickets. Phase 3 makes the system get smarter over time:
**Knowledge Flywheel:** Every resolved session is analyzed. The system proposes new flows from novel resolutions, suggests enhancements to existing flows when it discovers new branches, and reinforces proven flows when sessions follow known paths. Human-in-the-loop Review Queue ensures quality.
**In-Session Script Generator:** FlowPilot can invoke the Script Generator contextually during diagnosis. When it detects the engineer needs a PowerShell script (e.g., "reset this user's AD password"), it surfaces the script generator with parameters pre-filled from session context.
**AI-Enhanced Analytics:** MTTR trends, resolution rates by category, knowledge coverage heatmap, FlowPilot accuracy metrics, knowledge gap detection, and flow quality scoring.
---
## Slice 1: Flow Proposals Model & Post-Session Analysis
### Task 1: Create FlowProposal model
**Files:**
- Create: `backend/app/models/flow_proposal.py`
```python
"""Flow proposal model.
Generated by the Knowledge Flywheel after AI sessions resolve.
Represents a proposed new flow or enhancement awaiting human review.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.account import Account
from app.models.tree import Tree
from app.models.ai_session import AISession
class FlowProposal(Base):
"""A proposed new flow or enhancement generated from an AI session.
proposal_type:
- new_flow: No similar flow exists. Full flow definition proposed.
- enhancement: Similar flow exists but session discovered new branch/edge case.
- branch_addition: A single new branch to add to an existing flow.
status:
- pending: Awaiting review
- approved: Reviewed and published to knowledge base
- modified: Reviewer edited before publishing
- rejected: Reviewer decided not to publish (bad quality)
- dismissed: Parked for later — not wrong, just not actionable now. Can resurface if supporting_session_count grows.
- auto_reinforced: Session matched existing flow exactly (no review needed)
"""
__tablename__ = "flow_proposals"
__table_args__ = (
CheckConstraint(
"proposal_type IN ('new_flow', 'enhancement', 'branch_addition')",
name="ck_flow_proposals_type",
),
CheckConstraint(
"status IN ('pending', 'approved', 'modified', 'rejected', 'dismissed', 'auto_reinforced')",
name="ck_flow_proposals_status",
),
)
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,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
source_session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# ── Proposal details ──
proposal_type: Mapped[str] = mapped_column(
String(30), nullable=False,
)
target_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
comment="For enhancements: which existing flow to modify",
)
title: Mapped[str] = mapped_column(
String(255), nullable=False,
comment="Human-readable title for the proposed flow",
)
description: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="AI-generated description of what this flow covers",
)
proposed_flow_data: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False,
comment="Complete flow/tree_structure definition (nodes, edges, conditions)",
)
proposed_diff: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True,
comment="For enhancements: what changed vs existing flow",
)
# ── Scoring ──
confidence_score: Mapped[float] = mapped_column(
Float, nullable=False, default=0.0,
comment="How confident the system is in this proposal (0.0-1.0)",
)
supporting_session_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=1,
comment="Number of sessions with similar resolution paths",
)
supporting_session_ids: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list,
comment="Array of session IDs that support this proposal",
)
problem_domain: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True,
)
# ── Review ──
status: Mapped[str] = mapped_column(
String(30), nullable=False, default="pending", index=True,
)
reviewed_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewer_notes: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
)
published_flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
comment="The flow that was created/updated when this proposal was approved",
)
# ── Timestamps ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
reviewed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)
# ── Relationships ──
account: Mapped["Account"] = relationship("Account")
team: Mapped[Optional["Team"]] = relationship("Team")
source_session: Mapped["AISession"] = relationship("AISession")
target_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[target_flow_id])
published_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[published_flow_id])
reviewer: Mapped[Optional["User"]] = relationship("User")
```
Register in `app/models/__init__.py`:
```python
from .flow_proposal import FlowProposal
```
Add to `__all__`.
### Task 2: Create Alembic migration
Generate with:
```bash
cd /projects/patherly/backend
DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \
venv/bin/alembic revision --autogenerate -m "add flow_proposals table"
```
Indexes: `account_id`, `team_id`, `source_session_id`, `status`, `target_flow_id`, `created_at`.
**Verification:** Run `alembic upgrade head`. Verify table exists.
```
git commit -m "feat(knowledge): add FlowProposal model + migration"
```
### Task 3: Build post-session analysis service (Knowledge Flywheel engine)
**Files:**
- Create: `backend/app/services/knowledge_flywheel.py`
**Architecture:**
This service runs after every successful session resolution. It analyzes the session and produces one of three outcomes:
**1. New Flow Proposal (`new_flow`):**
- Triggered when: Session resolved with `confidence_tier = "discovery"` or `"exploring"`, AND no flow was matched (or match_score < 0.5)
- Process: Make an LLM call (standard tier) with the full session conversation, asking it to:
- Generate a flow title and description
- Convert the diagnostic path into a tree_structure (nodes, edges, conditions)
- Identify the key decision points and branching logic
- Suggest match_keywords for future semantic matching
- Store as a `FlowProposal` with `proposal_type = "new_flow"` and `status = "pending"`
**2. Flow Enhancement Proposal (`enhancement`):**
- Triggered when: Session matched an existing flow (match_score > 0.5) but diverged at some point (engineer used free-text escape or chose a path not in the flow)
- Process: LLM call comparing the session path with the matched flow, identifying:
- New branches that should be added
- Options that should be added to existing questions
- Steps that should be reordered based on what worked
- Store as `FlowProposal` with `proposal_type = "enhancement"`, `target_flow_id` set, and `proposed_diff` containing the changes
**3. Flow Reinforcement (`auto_reinforced`):**
- Triggered when: Session followed an existing flow closely (match_score > 0.8, no free-text escapes, resolution matched the flow's expected outcome)
- Process: No LLM call needed. Update the flow's `success_rate` and `last_matched_at`. Create a `FlowProposal` with `status = "auto_reinforced"` for tracking purposes only (no review needed).
**Key implementation details:**
- **Do NOT use `asyncio.create_task()`.** Use the APScheduler background job pattern established by `psa_retry_scheduler.py`. Add an `analysis_status` column to `AISession` (values: `null`, `pending`, `completed`, `failed`). Set it to `pending` in `resolve_session()`. A periodic scheduler job picks up pending sessions and runs analysis. This is resilient to server restarts and retryable on failure.
- The LLM call for flow generation should use the existing `ai_provider.generate_json()` with a specific system prompt for flow construction
- The generated `tree_structure` must match the existing tree format used by the Flow Editor (check `models/tree.py``tree_structure` JSONB schema). Troubleshooting flows use nested `children` nodes; procedural flows use linear `steps` arrays. The Knowledge Flywheel should generate **troubleshooting** tree format for diagnostic sessions.
- **Data source:** `AISession` uses `conversation_messages` (JSONB list of role/content dicts) and the `steps` relationship (`AISessionStep` with `step_type`, `content`, `selected_option`, `free_text_input`, `action_result`). Build session context from these — do NOT reference `session.decisions` or `session.scratchpad` (those are legacy `Session` model fields).
- For enhancement proposals, also generate a human-readable diff description (e.g., "Added new branch for 'Error code 0x80070005' at step 3")
- Track supporting sessions: if multiple sessions resolve a similar novel problem, increase `supporting_session_count` and `confidence_score` on the existing proposal rather than creating duplicates. Use `problem_domain` match + embedding similarity on `title + description` against existing pending proposals (threshold >0.85 cosine similarity → merge)
- **Rate limiting:** Add a daily budget cap for proposal generation LLM calls (configurable, default 50/day per account) to prevent runaway costs from high-volume teams
**System prompt for flow generation (excerpt):**
```python
FLOW_GENERATION_PROMPT = """You are a knowledge engineer converting a troubleshooting session into a reusable flow definition.
Given the session transcript below, generate a JSON flow definition that captures the diagnostic logic so other engineers can follow the same path.
## OUTPUT FORMAT
Respond with ONLY valid JSON:
{
"title": "Short descriptive title",
"description": "When to use this flow",
"match_keywords": ["keyword1", "keyword2", ...],
"problem_domain": "active_directory | networking | m365 | ...",
"tree_structure": {
"id": "root",
"type": "question",
"question": "First diagnostic question",
"children": [
{
"id": "opt1",
"label": "Option text",
"type": "question | action | solution",
...
}
]
}
}
## RULES
- tree_structure must follow the ResolutionFlow tree format
- Every question node must have 2-5 children (options)
- Action nodes describe what the engineer should do
- Solution nodes describe the resolution
- Include the key diagnostic questions that narrowed down the problem
- Skip redundant or dead-end paths from the session
- match_keywords should be symptoms, error messages, and technology names
"""
```
**Verification:** Resolve an AI session (discovery mode, no matched flow). Wait 2-3 seconds. Check `flow_proposals` table — verify a `new_flow` proposal was created with a valid `tree_structure`. Resolve a session that matched an existing flow but diverged — verify an `enhancement` proposal was created. Resolve a session that followed a flow exactly — verify an `auto_reinforced` record was created and the flow's stats were updated.
```
git commit -m "feat(knowledge): add Knowledge Flywheel post-session analysis"
```
### Task 4: Wire Knowledge Flywheel into session resolution
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py` — set `analysis_status = "pending"` in `resolve_session()`
- Create: `backend/app/services/knowledge_flywheel_scheduler.py` — APScheduler job
- Edit: `backend/app/main.py` — register the scheduler job
- Migration: add `analysis_status` column to `ai_sessions` table
**Migration:** Add `analysis_status` column:
```python
# String(20), nullable=True, default=None
# Values: null (not applicable), "pending", "completed", "failed"
op.add_column('ai_sessions', sa.Column('analysis_status', sa.String(20), nullable=True))
```
**In `resolve_session()`**, after documentation is generated and PSA push is queued:
```python
session.analysis_status = "pending"
```
**Scheduler (`knowledge_flywheel_scheduler.py`):**
Follow the same pattern as `psa_retry_scheduler.py`:
```python
async def process_pending_analyses() -> None:
"""Process resolved sessions awaiting Knowledge Flywheel analysis."""
async with async_session_maker() as db:
result = await db.execute(
select(AISession)
.options(selectinload(AISession.steps))
.where(AISession.analysis_status == "pending")
.limit(10)
)
sessions = result.scalars().all()
for session in sessions:
try:
await analyze_session(session, db)
session.analysis_status = "completed"
except Exception as e:
logger.warning("Knowledge Flywheel failed for %s: %s", session.id, e)
session.analysis_status = "failed"
await db.commit()
```
Register in `main.py` lifespan alongside the existing PSA retry scheduler (5-minute interval).
**Verification:** Resolve a session. Confirm the response returns immediately. Wait for the scheduler tick (~5 min or trigger manually). Check `flow_proposals` table — confirm the proposal was created and `analysis_status = "completed"`.
```
git commit -m "feat(knowledge): wire Knowledge Flywheel into session resolution via scheduler"
```
---
## Slice 2: Review Queue
### Task 5: Create Review Queue API endpoints
**Files:**
- Create: `backend/app/schemas/flow_proposal.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py` (or create a new `flow_proposals.py` router)
**Schemas:**
```python
class FlowProposalSummary(BaseModel):
id: UUID
proposal_type: str
title: str
description: str | None
problem_domain: str | None
confidence_score: float
supporting_session_count: int
status: str
target_flow_id: UUID | None
target_flow_name: str | None # Joined from trees table
source_session_id: UUID
created_at: datetime
model_config = {"from_attributes": True}
class FlowProposalDetail(FlowProposalSummary):
proposed_flow_data: dict[str, Any]
proposed_diff: dict[str, Any] | None
supporting_session_ids: list[str]
reviewer_notes: str | None
reviewed_by: UUID | None
reviewed_at: datetime | None
class ReviewProposalRequest(BaseModel):
action: str # "approve" | "reject" | "modify"
reviewer_notes: str | None = None
modified_flow_data: dict[str, Any] | None = None # Only for "modify"
class FlowProposalStats(BaseModel):
pending_count: int
approved_this_week: int
rejected_this_week: int
auto_reinforced_this_week: int
top_domains: list[dict[str, Any]] # [{domain, count}]
```
**Endpoints:**
```
GET /api/v1/flow-proposals — List proposals (filterable by status, type, domain)
GET /api/v1/flow-proposals/stats — Dashboard stats for the review queue
GET /api/v1/flow-proposals/{id} — Get proposal detail with full flow data
POST /api/v1/flow-proposals/{id}/review — Approve, reject, or modify a proposal
```
**Auth:** `require_engineer_or_admin` for listing/detail. Review actions (approve/reject/modify) require inline check: `if not (current_user.is_super_admin or current_user.is_team_admin): raise HTTPException(403, "Team admin required")`. No existing `require_team_admin` dep exists — add one to `api/deps.py` or use inline checks.
**Review flow:**
- **Approve:** Create a new `Tree` from `proposed_flow_data` (for `new_flow`) or update the existing tree (for `enhancement`). Set `tree.origin = "ai_generated"` or `"ai_enhanced"`. Set `tree.source_session_id`. Set proposal `status = "approved"`, `published_flow_id` = new tree ID.
- **Modify:** Same as approve, but use `modified_flow_data` instead of `proposed_flow_data`. Set proposal `status = "modified"`.
- **Reject:** Set proposal `status = "rejected"`. No flow changes.
- **Dismiss:** Set proposal `status = "dismissed"`. Unlike reject (bad quality), dismiss means "not now" — the proposal can resurface if `supporting_session_count` grows. Add `"dismissed"` to the `FlowProposal` status constraint.
**NOTE on `session_to_flow_service.py`:** This service works with the legacy `Session` model and CANNOT be called directly for `AISession`-based proposals. The Knowledge Flywheel generates `proposed_flow_data` in its own Task 3 — by the time we reach the Review Queue, the flow structure is already in the proposal. The review endpoint just needs to create a `Tree` from the pre-generated `proposed_flow_data` dict (set `tree_type`, `tree_structure`, `origin`, `source_session_id`, etc.). No LLM call needed at review time.
**Verification:** Create a few proposals via the Knowledge Flywheel. Hit the list endpoint. Review one (approve). Verify a new tree was created. Review another (reject). Verify no tree change.
```
git commit -m "feat(knowledge): add Review Queue API endpoints"
```
### Task 6: Build Review Queue frontend
**Files:**
- Create: `frontend/src/pages/ReviewQueuePage.tsx`
- Create: `frontend/src/components/flowpilot/ProposalCard.tsx`
- Create: `frontend/src/components/flowpilot/ProposalDetail.tsx`
- Create: `frontend/src/components/flowpilot/ProposalDiffView.tsx`
- Create: `frontend/src/components/flowpilot/ReviewActions.tsx`
- Create: `frontend/src/api/flowProposals.ts`
- Create: `frontend/src/types/flow-proposal.ts`
- Edit: `frontend/src/router.tsx`
- Edit sidebar navigation
**Page layout:** Two-panel design similar to the Script Library page.
**Left panel — Proposal list:**
- Filter tabs: "Pending" (default), "Approved", "Rejected", "Dismissed", "All"
- Filter by domain (dropdown)
- Sort by: newest, highest confidence, most supporting sessions
- Each card shows: title, proposal type badge (`new_flow` green, `enhancement` amber, `branch_addition` blue), domain badge, confidence score, supporting session count, created date
**Right panel — Proposal detail:**
- Full proposal info: title, description, source session link, confidence
- **For new_flow:** Flow preview — render the proposed `tree_structure` using a simplified read-only version of the flow editor or a tree visualization
- **For enhancement:** Diff view showing what would change on the target flow (added nodes highlighted green, modified nodes highlighted amber)
- Source session link — click to open the session that generated this proposal in read-only mode
- Supporting sessions list (if count > 1)
**Review actions (bottom bar):**
- "Approve & Publish" (green) — creates the flow immediately
- "Edit & Publish" — uses `navigate('/editor/new', { state: { preloadedStructure: proposedFlowData, proposalId } })` to open the Flow Editor with the proposed structure pre-loaded (same `location.state` pattern used by `CreateFlowDropdown``AIPromptDialog`, see Lesson 46)
- "Dismiss" (muted) — parks the proposal for later; can resurface if supporting sessions grow
- "Reject" (red) — with optional reason textarea
- Reviewer notes input
**Navigation:**
- Add "Review Queue" to the sidebar under "Knowledge Base" section
- Show a badge with pending count if > 0 (similar to notification badges)
**Verification:** Navigate to Review Queue. See pending proposals. Click one. See the flow preview. Approve it. Verify a new tree appears in My Trees. Click "Edit & Publish" on another — verify it opens in the Flow Editor with the proposed structure pre-loaded.
```
git commit -m "feat(knowledge): add Review Queue frontend"
```
---
## Slice 3: Knowledge Gap Detection
### Task 7: Build knowledge gap detection service
**Files:**
- Create: `backend/app/services/knowledge_gap_service.py`
**Architecture:**
This service aggregates signals from AI sessions to identify gaps in the knowledge base:
**Signal 1 — Frequent free-text escapes:**
- Query `ai_session_steps` where `was_free_text = true`
- Group by the `content` field (the question that was asked) and count
- High counts indicate FlowPilot's options don't cover a common scenario
- Return: list of questions with high free-text rates and the common free-text inputs
**Signal 2 — High escalation rate by domain:**
- Query `ai_sessions` where `status = "escalated"`, group by `problem_domain`
- Compare escalation rate vs resolution rate per domain
- Domains with >40% escalation rate are flagged as knowledge gaps
**Signal 3 — Discovery-mode resolutions:**
- Query `ai_sessions` where `status = "resolved"` AND `confidence_tier = "discovery"` at resolution
- These are novel problems that were solved — highest-value knowledge capture opportunities
- Group by `problem_domain` and rank by frequency
**Signal 4 — Repeated similar intake patterns (DESCOPED to Phase 4):**
~~Use embedding similarity on `intake_content.text` across recent sessions and cluster similar intakes.~~
**Reason:** The codebase has point-query embedding support (Voyage AI) but no batch embedding or clustering infrastructure. Implementing vector clustering (DBSCAN/k-means) over session embeddings is a significant undertaking. **Phase 3 alternative:** Use keyword frequency analysis on `problem_domain` + `problem_summary` text to find repeated unmatched patterns. Full embedding clustering deferred to Phase 4.
**Return type:**
```python
class KnowledgeGapReport(BaseModel):
generated_at: datetime
gaps: list[KnowledgeGap]
class KnowledgeGap(BaseModel):
gap_type: str # "weak_options" | "high_escalation" | "uncharted_territory" | "repeated_pattern"
domain: str | None
severity: str # "high" | "medium" | "low"
title: str
description: str
evidence: dict[str, Any] # Supporting data (counts, examples, session IDs)
suggested_action: str # What to do about it
```
**Endpoint:**
```
GET /api/v1/analytics/knowledge-gaps
```
Returns the current knowledge gap report. Cache for 1 hour (expensive query).
**Verification:** Run several AI sessions across different domains. Some should escalate, some should use free-text. Hit the knowledge gaps endpoint. Verify it returns reasonable gap analysis.
```
git commit -m "feat(knowledge): add knowledge gap detection service"
```
---
## Slice 4: In-Session Script Generator
### Task 8: Enable FlowPilot to invoke Script Generator during sessions
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `frontend/src/components/flowpilot/FlowPilotStepCard.tsx`
- Create: `frontend/src/components/flowpilot/InSessionScriptGenerator.tsx`
**Backend — System prompt enhancement:**
Add available script templates to FlowPilot's system prompt context. When building the system prompt in `_build_system_prompt()`, include:
```python
# Query available script templates
templates = await db.execute(
select(ScriptTemplate)
.where(ScriptTemplate.is_active == True)
.where(or_(ScriptTemplate.team_id == None, ScriptTemplate.team_id == team_id))
.order_by(ScriptTemplate.usage_count.desc())
.limit(20)
)
template_list = templates.scalars().all()
# Add to system prompt
script_context = "\n--- AVAILABLE SCRIPTS ---\n"
for t in template_list:
script_context += f"- {t.name} (ID: {t.id}): {t.description}\n"
script_context += f" Parameters: {', '.join(p['key'] for p in t.parameters_schema.get('parameters', []))}\n"
script_context += "\nWhen the engineer needs to run a script, suggest a script_generation action with the template_id and pre-fill parameters from the diagnostic context.\n"
```
**Backend — Structured output for script actions:**
FlowPilot already supports `action_type: "script_generation"` in its structured output contract (defined in Phase 1). When FlowPilot returns this type, the response includes:
```json
{
"type": "action",
"action_type": "script_generation",
"template_id": "uuid-of-the-template",
"pre_filled_params": {
"sam_account_name": "jsmith",
"ou_path": "OU=Users,DC=contoso,DC=com"
},
"instructions": "Generate a password reset script for this user",
"confidence": 0.85
}
```
The backend should validate that `template_id` exists and is accessible to the user's team.
**IMPORTANT — Migration needed:** `ScriptGeneration.session_id` currently FKs to legacy `sessions.id`, NOT `ai_sessions.id`. Add a migration to add `ai_session_id` FK column to `script_generations`:
```python
op.add_column('script_generations', sa.Column(
'ai_session_id', sa.UUID(), sa.ForeignKey('ai_sessions.id', ondelete='SET NULL'), nullable=True
))
op.create_index('ix_script_generations_ai_session_id', 'script_generations', ['ai_session_id'])
```
Update `ScriptGeneration` model to include `ai_session_id` mapped column. The existing `session_id` FK stays for legacy sessions.
**Frontend — `InSessionScriptGenerator` component:**
When `FlowPilotStepCard` receives a step with `action_type === "script_generation"`:
1. Render the step card with a script generation UI embedded inline
2. Reuse the existing `ScriptParameterForm` component from the Script Library
3. Pre-fill parameters from `pre_filled_params` in the step content
4. Engineer can edit parameters and generate the script
5. On generation, call the existing `POST /api/v1/scripts/generate` endpoint with `ai_session_id` set to the current AI session
6. Display the generated script with the existing `ScriptPreview` component (PowerShell syntax highlighting)
7. Copy/download buttons
8. "Script generated" event is captured in `ai_session_steps` with `step_type = "script_generation"` and `script_generation_id` FK populated
9. After generating, show "Continue" button → engineer reports result back to FlowPilot
**Key reuse:** Import and compose these existing components from `src/components/scripts/`:
- `ScriptParameterForm` — dynamic form from parameter schema
- `ScriptPreview` — PowerShell syntax highlighting
- `PowerShellHighlighter` — tokenizer
**Do NOT rebuild these components.** Wrap them in `InSessionScriptGenerator` which handles the session context (pre-filling, event capture, continue flow).
**Verification:** Start an AI session about an AD issue. Progress until FlowPilot suggests a script generation action. See the script generator appear inline. Parameters should be pre-filled from conversation context. Generate the script. Copy it. Click continue. Verify the step is captured in session docs with `script_generation_id` populated.
```
git commit -m "feat(ai-session): add in-session Script Generator integration"
```
---
## Slice 5: AI-Enhanced Analytics Dashboard
### Task 9: Build FlowPilot analytics API endpoints
**Files:**
- Create: `backend/app/schemas/flowpilot_analytics.py`
- Create: `backend/app/api/endpoints/flowpilot_analytics.py`
- Edit: `backend/app/api/router.py`
**Schemas:**
```python
class FlowPilotDashboard(BaseModel):
"""Top-level analytics dashboard data."""
period: str # "7d" | "30d" | "90d"
total_sessions: int
resolved_sessions: int
escalated_sessions: int
abandoned_sessions: int
resolution_rate: float # percentage
avg_steps_to_resolution: float
avg_session_duration_minutes: float
avg_rating: float | None
mttr_minutes: float | None # Mean Time To Resolution
mttr_trend: list[MTTRDataPoint] # For chart
sessions_by_domain: list[DomainBreakdown]
confidence_breakdown: ConfidenceBreakdown
knowledge_coverage: KnowledgeCoverage
psa_metrics: PsaMetrics | None # None if no PSA connection
class MTTRDataPoint(BaseModel):
date: str # ISO date
mttr_minutes: float
session_count: int
class DomainBreakdown(BaseModel):
domain: str
total: int
resolved: int
escalated: int
resolution_rate: float
class ConfidenceBreakdown(BaseModel):
guided_sessions: int
guided_resolution_rate: float
exploring_sessions: int
exploring_resolution_rate: float
discovery_sessions: int
discovery_resolution_rate: float
class KnowledgeCoverage(BaseModel):
total_flows: int
ai_generated_flows: int
total_proposals_pending: int
proposals_approved_this_period: int
proposals_rejected_this_period: int
coverage_by_domain: list[DomainCoverage]
class DomainCoverage(BaseModel):
domain: str
flow_count: int
session_count: int
guided_rate: float # % of sessions in this domain that hit "guided" confidence
class PsaMetrics(BaseModel):
"""PSA integration metrics (Phase 2 ROI data)."""
ticket_link_rate: float # % of sessions linked to a PSA ticket
auto_push_success_rate: float # % of pushes that succeeded on first try
auto_push_retry_success_rate: float # % that succeeded after retries
total_time_entries_logged: int
total_hours_logged: float
```
**Endpoints:**
```
GET /api/v1/analytics/flowpilot?period=30d — Main dashboard data
GET /api/v1/analytics/flowpilot/mttr-trend?period=90d — MTTR trend chart data
GET /api/v1/analytics/flowpilot/knowledge-gaps — Knowledge gap report (from Task 7)
```
**Auth:** Team admin or owner. Scope to account.
**Key queries:**
- MTTR: `AVG(resolved_at - created_at)` for sessions where `status = "resolved"`, grouped by date
- Confidence breakdown: `COUNT(*) GROUP BY confidence_tier` for resolved sessions
- Domain breakdown: `COUNT(*), SUM(CASE WHEN status='resolved')` grouped by `problem_domain`
- Knowledge coverage: Count flows per domain vs session count per domain. High session count + low flow count = poor coverage.
**Verification:** Run several AI sessions over different days (can seed test data). Hit the analytics endpoint. Verify all fields are populated with reasonable values.
```
git commit -m "feat(analytics): add FlowPilot analytics API"
```
### Task 10: Build FlowPilot analytics dashboard frontend
**Files:**
- Create: `frontend/src/pages/FlowPilotAnalyticsPage.tsx`
- Create: `frontend/src/components/flowpilot/analytics/MTTRChart.tsx`
- Create: `frontend/src/components/flowpilot/analytics/DomainBreakdownChart.tsx`
- Create: `frontend/src/components/flowpilot/analytics/ConfidenceBreakdown.tsx`
- Create: `frontend/src/components/flowpilot/analytics/KnowledgeCoverageMap.tsx`
- Create: `frontend/src/components/flowpilot/analytics/KnowledgeGapsPanel.tsx`
- Create: `frontend/src/api/flowpilotAnalytics.ts`
- Create: `frontend/src/types/flowpilot-analytics.ts`
- Edit: `frontend/src/router.tsx`
**Design:** Follow existing `TeamAnalyticsPage.tsx` patterns. Use Recharts for charts (already a project dependency).
**Layout:**
**Top row — Key metrics cards:**
- Total sessions (with trend arrow)
- Resolution rate (with trend)
- Average MTTR (with trend)
- Average rating (with star display)
- PSA ticket link rate (% of sessions linked to tickets)
**Second row — Charts:**
- MTTR trend area chart (Recharts `AreaChart` — matches existing `TeamAnalyticsPage.tsx` pattern, not `LineChart`)
- Domain breakdown bar chart (Recharts `BarChart`) — resolved vs escalated per domain
**Third row — Intelligence:**
- Confidence tier donut chart — guided vs exploring vs discovery, with resolution rate overlay
- Knowledge coverage heatmap — domains as rows, columns for flow count / session count / guided rate / gap severity. Color-coded: green (well covered), amber (needs work), red (major gap)
**Fourth row — Knowledge gaps:**
- `KnowledgeGapsPanel` — renders the knowledge gap report from Task 7
- Each gap as a card with severity badge, description, and suggested action
- "Create Flow" CTA on high-severity gaps → opens the Flow Editor with suggested structure
**Period selector:** Dropdown in the page header — 7 days, 30 days, 90 days.
**Navigation:** Add "FlowPilot Analytics" under the existing "Analytics" section in the sidebar.
**Verification:** Navigate to the analytics page. Select different periods. Verify charts render with real data. Check knowledge gaps section shows actionable insights.
```
git commit -m "feat(analytics): add FlowPilot analytics dashboard"
```
---
## Summary of All New/Modified Files
### Backend — New
```
app/models/flow_proposal.py # FlowProposal model
app/services/knowledge_flywheel.py # Post-session analysis engine
app/services/knowledge_flywheel_scheduler.py # APScheduler job for async analysis
app/services/knowledge_gap_service.py # Knowledge gap detection
app/schemas/flow_proposal.py # Proposal schemas
app/schemas/flowpilot_analytics.py # Analytics schemas
app/api/endpoints/flow_proposals.py # Review Queue API
app/api/endpoints/flowpilot_analytics.py # Analytics API
alembic/versions/xxx_add_flow_proposals.py # Migration (flow_proposals table)
alembic/versions/xxx_add_analysis_status.py # Migration (ai_sessions.analysis_status)
alembic/versions/xxx_add_ai_session_id_to_scripts.py # Migration (script_generations.ai_session_id)
```
### Backend — Modified
```
app/models/__init__.py # Register FlowProposal
app/models/ai_session.py # Add analysis_status column
app/models/script_template.py # Add ai_session_id FK to ScriptGeneration
app/api/deps.py # Add require_team_admin dependency
app/api/router.py # Register new routers
app/main.py # Register knowledge_flywheel_scheduler
app/services/flowpilot_engine.py # Set analysis_status, add script context to system prompt
```
### Frontend — New
```
src/pages/ReviewQueuePage.tsx # Review Queue page
src/pages/FlowPilotAnalyticsPage.tsx # Analytics dashboard
src/components/flowpilot/ProposalCard.tsx # Proposal list card
src/components/flowpilot/ProposalDetail.tsx # Proposal detail panel
src/components/flowpilot/ProposalDiffView.tsx # Enhancement diff viewer
src/components/flowpilot/ReviewActions.tsx # Approve/reject/modify bar
src/components/flowpilot/InSessionScriptGenerator.tsx # Script gen embedded in session
src/components/flowpilot/analytics/MTTRChart.tsx # MTTR trend chart
src/components/flowpilot/analytics/DomainBreakdownChart.tsx
src/components/flowpilot/analytics/ConfidenceBreakdown.tsx
src/components/flowpilot/analytics/KnowledgeCoverageMap.tsx
src/components/flowpilot/analytics/KnowledgeGapsPanel.tsx
src/api/flowProposals.ts # Proposals API client
src/api/flowpilotAnalytics.ts # Analytics API client
src/types/flow-proposal.ts # Proposal types
src/types/flowpilot-analytics.ts # Analytics types
```
### Frontend — Modified
```
src/components/flowpilot/FlowPilotStepCard.tsx # Handle script_generation action type
src/router.tsx # Review Queue + Analytics routes
src/components/sidebar/ # New nav entries
```
---
## Database Changes
**Migration:** Create `flow_proposals` table with all columns, constraints, and indexes.
**Run migration:**
```bash
cd /projects/patherly/backend
DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \
venv/bin/alembic upgrade head
```
---
## Testing Strategy
### Backend Unit Tests
**Files:** `backend/tests/test_knowledge_flywheel.py`
- Test new_flow proposal generation from a discovery-mode session
- Test enhancement proposal generation from a divergent session
- Test auto_reinforcement for matching sessions
- Test duplicate detection (similar proposals get merged)
- Test generated tree_structure matches the expected format
**Files:** `backend/tests/test_knowledge_gap_service.py`
- Test free-text escape detection
- Test escalation rate calculation by domain
- Test discovery-mode session grouping
**Files:** `backend/tests/test_flow_proposals_api.py`
- Test list/filter proposals
- Test approve → verify tree created
- Test modify → verify modified data used
- Test reject → verify no tree change
- Test RBAC (non-admin can't review)
### Frontend Manual Testing
1. Resolve several AI sessions (mix of discovery, exploring, guided)
2. Navigate to Review Queue — verify proposals appear
3. Approve a new_flow proposal — verify tree appears in library
4. Click "Edit & Publish" — verify Flow Editor opens with proposed structure
5. Start a session about an AD issue — progress until FlowPilot suggests a script — generate it inline
6. Navigate to FlowPilot Analytics — verify all charts render with data
7. Check Knowledge Gaps — verify actionable insights appear
---
## What Comes Next (Phase 4+ — NOT in scope here)
- **Template Marketplace:** Public templates gallery for SEO + lead gen
- **Multi-PSA support:** Autotask, Halo PSA, Datto PSA integrations
- **SSO/SAML:** Enterprise authentication
- **Custom AI training:** Per-account fine-tuning on company procedures
- **Mobile optimization:** Responsive design pass for tablet/phone sessions
- **Webhook integrations:** Slack notifications, Teams alerts on escalation
- **API for third-party tools:** Public API for RMM/PSA vendors to integrate

View File

@@ -0,0 +1,828 @@
# FlowPilot-First Pivot — Phase 4: Growth, Polish & Enterprise Readiness
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
>
> **Status (2026-03-19):** Slice 2 (Notifications) COMPLETE. Slice 3 (Session Export) ~80% COMPLETE (needs polish only). Remaining: Slices 1, 4, 5. Execute in order: Slice 1 → Slice 3 polish → Slice 4 → Slice 5.
**Goal:** Prepare ResolutionFlow for market growth and enterprise customers. Build the public templates gallery for SEO/lead-gen, add notification integrations (Slack/Teams/email) for escalation alerts, polish the mobile/responsive experience, add session export capabilities, and lay the groundwork for enterprise features (SSO prep, custom branding, multi-PSA).
**Architecture:** This phase is less about new core architecture and more about extending what exists. Templates gallery is a public-facing read-only layer over the existing flow/script template system. Notifications add webhook/event infrastructure. Mobile polish is a responsive design pass. Enterprise features add configuration surfaces.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), React, TypeScript, Tailwind CSS v4 (@tailwindcss/vite), Recharts, Resend (email notifications), weasyprint (PDF export), slowapi (rate limiting)
**Prerequisites:**
- Phase 1 complete (AI session core)
- Phase 2 complete (PSA integration, escalation handoff)
- Phase 3 complete (Knowledge Flywheel, Review Queue, in-session Script Generator, analytics)
- Existing models: `Tree`, `ScriptTemplate`, `AISession`, `FlowProposal`, `PsaConnection`
- Existing services: All Phase 1-3 services
- Existing frontend: All Phase 1-3 pages and components
**Pivot architecture doc:** `docs/ResolutionFlow_Pivot_Architecture.docx`
**Product strategy doc:** `docs/ResolutionFlow_Product_Strategy_Review.docx`
---
## Context: What Phase 4 Adds
Phases 1-3 built a complete AI-powered resolution engine with PSA integration, knowledge learning, and analytics. Phase 4 is about growth and polish — making the product ready for broader adoption and enterprise buyers.
**Public Templates Gallery:** A public-facing page at `/templates` (no auth required) that showcases curated flow templates and script templates. This is the primary SEO and lead-gen surface identified in the Product Strategy Review. Engineers discover ResolutionFlow by searching for troubleshooting guides, see the value, and sign up.
**Notification Integrations:** When a session is escalated, the receiving engineer (and team leads) should be notified via Slack, Microsoft Teams, or email. Also notify on: high-priority ticket sessions, Knowledge Flywheel proposals pending review, and session completion for tracked tickets.
**Session Export & Sharing:** Export session documentation as PDF or formatted text for sharing outside ResolutionFlow. "Generated with ResolutionFlow" branding on exports for viral growth (per Product Strategy Review).
**Mobile/Responsive Polish:** The FlowPilot session experience must work on tablets and phones. MSP techs often troubleshoot on-site with a tablet. This is a responsive design pass, not a native app.
**Enterprise Readiness:** Custom branding (logo, colors), SAML/SSO groundwork, multi-PSA support (Autotask, Halo PSA adapter stubs), and admin controls for AI model selection.
---
## Slice 1: Public Templates Gallery
### Task 1: Build public templates API (no auth required)
**Files:**
- Create: `backend/app/api/endpoints/public_templates.py`
- Create: `backend/app/schemas/public_templates.py`
**Schemas:**
```python
class PublicFlowTemplate(BaseModel):
"""A flow template visible in the public gallery."""
id: UUID
name: str
description: str | None
category: str | None
problem_domain: str | None
tree_type: str
step_count: int
usage_count: int
success_rate: float | None
tags: list[str]
preview_structure: dict[str, Any] # Simplified tree for preview (first 2-3 levels only)
created_at: datetime
class PublicScriptTemplate(BaseModel):
"""A script template visible in the public gallery."""
id: UUID
name: str
description: str | None
use_case: str | None
category_name: str
category_icon: str | None
complexity: str
tags: list[str]
parameter_count: int
requires_elevation: bool
requires_modules: list[str]
usage_count: int
is_verified: bool
created_at: datetime
class PublicGalleryResponse(BaseModel):
"""Combined gallery response."""
flow_templates: list[PublicFlowTemplate]
script_templates: list[PublicScriptTemplate]
total_flows: int
total_scripts: int
categories: list[str]
domains: list[str]
```
**Endpoints (NO authentication required):**
```
GET /api/v1/public/templates — Gallery listing (paginated, filterable)
GET /api/v1/public/templates/flows/{id} — Flow template detail (preview only, no full structure)
GET /api/v1/public/templates/scripts/{id} — Script template detail (preview only, no script body)
GET /api/v1/public/templates/categories — List all categories with counts
GET /api/v1/public/templates/search — Full-text search across flows + scripts
```
**Key implementation details:**
- Only expose flows/scripts marked as `visibility = "public"` or a new `is_gallery_featured` boolean
- Add `is_gallery_featured` boolean column to both `trees` and `script_templates` tables (migration required)
- `preview_structure` should be a truncated version of `tree_structure` — first 2-3 levels only, no deep branches. Don't expose the full flow to unauthenticated users — they need to sign up for that.
- Script templates in the gallery show parameter names and description but NOT the `script_body` — that's the value behind the signup wall
- Rate limit public endpoints via `slowapi` (standard FastAPI rate limiter): 30/minute per IP. The existing `core/rate_limit.py` is auth-based (per-user); public endpoints need IP-based limiting. Add `slowapi` to requirements and configure a `Limiter` instance with `get_remote_address` as the key function. Apply `@limiter.limit("30/minute")` to each public endpoint.
- Add OpenGraph meta tags support for social sharing (title, description, image)
- Cache responses aggressively (5-minute TTL) since public content changes infrequently
**SEO consideration:** These endpoints power the public page at `resolutionflow.com/templates`. The response should include fields that map well to structured data (schema.org HowTo or SoftwareApplication).
**Note (deferred):** React SPAs serve empty HTML to social media scrapers (Facebook, Slack, LinkedIn), so OpenGraph meta tags won't render in link previews. Options for Phase 5: prerendering service (prerender.io), build-time static HTML generation for gallery pages, or backend-side meta tag injection. Google's crawler handles JS fine, but social sharing will be limited until addressed.
**Verification:** Hit the public templates endpoint without auth. Verify flows and scripts with `is_gallery_featured = true` appear. Verify full script bodies and deep flow structures are NOT exposed.
```
git commit -m "feat(public): add public templates gallery API"
```
### Task 2: Build public templates gallery frontend
**Files:**
- Create: `frontend/src/pages/PublicTemplatesPage.tsx`
- Create: `frontend/src/components/public/TemplateGalleryGrid.tsx`
- Create: `frontend/src/components/public/FlowTemplateCard.tsx`
- Create: `frontend/src/components/public/ScriptTemplateCard.tsx`
- Create: `frontend/src/components/public/TemplateDetailModal.tsx`
- Create: `frontend/src/components/public/GallerySearch.tsx`
- Create: `frontend/src/api/publicTemplates.ts`
- Create: `frontend/src/types/public-templates.ts`
- Edit: `frontend/src/router.tsx`
**Route:** `/templates` — public, no auth required. Add to the router OUTSIDE the `ProtectedRoute` wrapper, alongside `/landing`, `/login`, `/register`.
**Design:** This is the first page many potential users will see. It needs to be visually impressive and clearly communicate value.
**Layout:**
**Hero section:**
- Heading: "MSP Troubleshooting Templates" (Bricolage Grotesque, large)
- Subheading: "Battle-tested flows and scripts built by MSP engineers. Free to browse, powerful when connected to FlowPilot."
- Search bar (prominent, centered)
- CTA: "Sign Up Free" button
**Filter bar:**
- Category pills: "All", "Active Directory", "Networking", "Microsoft 365", "Security", etc.
- Type toggle: "Flows", "Scripts", "All"
- Sort: "Most Used", "Newest", "Highest Success Rate"
**Grid:**
- Responsive card grid: 3 columns desktop, 2 tablet, 1 mobile
- Flow cards: name, description, domain badge, step count, success rate, usage count, "Preview" button
- Script cards: name, description, complexity badge (color-coded), verified badge, module tags, "View Details" button
**Template detail modal:**
- For flows: show the first 2-3 levels of the tree as a simplified visual (not the full flow editor — just a clean tree diagram). "Sign up to use this flow with FlowPilot" CTA.
- For scripts: show parameter list, description, use case, modules required. "Sign up to generate this script" CTA. Do NOT show the script body.
**Footer on every card:** "Powered by ResolutionFlow" with subtle branding
**Verification:** Open `/templates` without being logged in. Browse the gallery. Search for "Active Directory". Filter by scripts. Click a card — see the preview modal with signup CTA. Verify no sensitive data (script bodies, deep flow structures) is exposed.
```
git commit -m "feat(public): add public templates gallery page"
```
### Task 3: Admin curation tools for gallery
**Files:**
- Edit: `frontend/src/pages/admin/` (add gallery management section)
- Edit: `backend/app/api/endpoints/admin.py` (or create `admin_gallery.py`)
**What to add:**
Admin interface to manage which flows and scripts appear in the public gallery:
- Toggle `is_gallery_featured` on/off for any flow or script template
- Reorder gallery items (add `gallery_sort_order` column)
- Set gallery category overrides (a flow might have a different display category in the gallery vs internally)
- Preview how a template will look in the public gallery
**Verification:** Log in as admin. Feature a flow in the gallery. Open `/templates` in an incognito window. Verify the flow appears.
```
git commit -m "feat(admin): add gallery curation tools"
```
---
## Slice 2: Notification Integrations — COMPLETE (2026-03-19)
> **Status:** Fully implemented in Phase 4 Slice 2 session. Models, service, API endpoints, in-app notifications, retry scheduler, frontend panel and settings all done. Skip to Slice 3.
### Task 4: Build notification event system
**Files:**
- Create: `backend/app/services/notification_service.py`
- Create: `backend/app/models/notification_config.py`
- Create: `backend/app/schemas/notification.py`
**Architecture:**
A lightweight event-driven notification system. Events are fired from various points in the codebase, and the notification service routes them to configured channels.
**Events:**
| Event | Trigger | Default Recipients |
|-------|---------|-------------------|
| `session.escalated` | Engineer escalates a session | Escalation target + team admins |
| `session.resolved` | Session resolved (if tracked) | Session owner |
| `proposal.pending` | Knowledge Flywheel creates proposal | Team admins |
| `proposal.approved` | Reviewer approves a proposal | Proposal source session engineer |
| `session.high_priority` | PSA ticket with high/critical priority starts a session | Team admins |
| `knowledge_gap.detected` | New high-severity knowledge gap identified | Team admins |
**Notification channels:**
- **Email** (via existing Resend integration — `settings.RESEND_API_KEY`)
- **Slack webhook** (simple `POST` to a webhook URL — no OAuth needed for v1)
- **Microsoft Teams webhook** (similar — Adaptive Card format)
**NotificationConfig model:**
```python
class NotificationConfig(Base):
__tablename__ = "notification_configs"
id: UUID (PK)
account_id: UUID (FK)
channel: str # "email" | "slack_webhook" | "teams_webhook"
webhook_url: str | None # For Slack/Teams
is_active: bool
events_enabled: JSONB # {"session.escalated": true, "proposal.pending": true, ...}
created_at: datetime
updated_at: datetime
```
**Service pattern:**
```python
async def notify(event: str, account_id: UUID, payload: dict, db: AsyncSession):
"""Fire a notification event. Routes to all active channels for this account."""
configs = await _get_active_configs(account_id, event, db)
for config in configs:
if config.channel == "email":
await _send_email_notification(event, payload, config)
elif config.channel == "slack_webhook":
await _send_slack_notification(event, payload, config)
elif config.channel == "teams_webhook":
await _send_teams_notification(event, payload, config)
```
**Key details:**
- Run notifications async (don't block the response) via `asyncio.create_task()`
- Slack format: Rich message with fields (session summary, escalation reason, link to session)
- Teams format: Adaptive Card with similar fields
- Email format: Simple HTML email using Resend (reuse existing email infrastructure)
- On webhook failure: log to `notification_logs` table and schedule retry via APScheduler (exponential backoff: 30s, 2m, 10m — max 3 retries). Same pattern as PSA push retry from Phase 2.
**NotificationLog model:**
```python
class NotificationLog(Base):
__tablename__ = "notification_logs"
id: UUID (PK)
notification_config_id: UUID (FK notification_configs.id)
event: str # "session.escalated", "proposal.pending", etc.
payload: JSONB # Event payload snapshot
status: str # "sent" | "failed" | "retrying" | "exhausted"
retry_count: int (default 0)
last_error: str | None
next_retry_at: datetime | None
created_at: datetime
delivered_at: datetime | None
```
**Verification:** Configure a Slack webhook. Escalate a session. Verify Slack message appears with session summary and link.
```
git commit -m "feat(notifications): add notification event system with email/Slack/Teams"
```
### Task 5: Wire notifications into session lifecycle
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/services/knowledge_flywheel.py`
- Edit: `backend/app/api/endpoints/flow_proposals.py`
**What to add:**
Add `notify()` calls at these points:
- `flowpilot_engine.escalate_session()` → fire `session.escalated`
- `flowpilot_engine.start_session()` → if PSA ticket has high/critical priority → fire `session.high_priority`
- `knowledge_flywheel.analyze_session()` → when creating a `pending` proposal → fire `proposal.pending`
- `flow_proposals.review_proposal()` → when approving → fire `proposal.approved`
All notification calls should use `asyncio.create_task()` to avoid blocking.
**Verification:** Configure email notifications. Escalate a session. Check email inbox. Configure Slack webhook. Create a high-priority ticket session. Verify Slack notification fires.
```
git commit -m "feat(notifications): wire notifications into session and proposal lifecycle"
```
### Task 6: Notification settings UI
**Files:**
- Create: `frontend/src/components/account/NotificationSettings.tsx`
- Edit: `frontend/src/pages/account/IntegrationsPage.tsx`
- Create: `frontend/src/api/notifications.ts`
- Create: `frontend/src/types/notification.ts`
**What to add:**
Under Account Settings → Integrations, add a "Notifications" section:
- **Email notifications:** Toggle per event type. Uses the account owner's email by default. Option to add additional email addresses.
- **Slack:** "Connect Slack" → paste webhook URL → test button → configure which events to send
- **Microsoft Teams:** Same pattern — paste webhook URL → test → configure events
Each channel shows a card with:
- Channel name + icon (Slack logo, Teams logo, email icon)
- Status indicator (active/inactive)
- Event toggles (checkboxes for each event type)
- Test button → sends a test notification to verify the webhook works
- Remove button
**Verification:** Open integrations page. Add a Slack webhook. Toggle escalation events on. Click test. Verify test message appears in Slack. Escalate a session. Verify real notification appears.
```
git commit -m "feat(notifications): add notification settings UI"
```
### Task 6.5: In-app notification center
**Files:**
- Edit: `frontend/src/components/layout/NotificationsPanel.tsx` (EXISTING — currently shows recent session activity only)
- Create: `backend/app/api/endpoints/notifications.py`
- Create: `backend/app/models/notification.py`
- Edit: `backend/app/api/router.py`
**Context:** An existing `NotificationsPanel` component already renders a bell icon with a dropdown in the top bar. It currently fetches recent sessions via `sessionsApi.list()` and shows them as an activity feed. This task extends it into a real notification center.
**Backend — Notification model:**
```python
class Notification(Base):
__tablename__ = "notifications"
id: UUID (PK)
account_id: UUID (FK)
user_id: UUID (FK users.id) # Recipient
event: str # "session.escalated", "proposal.pending", etc.
title: str # "Session escalated by John"
body: str | None # Brief summary
link: str | None # In-app link, e.g. "/pilot/{session_id}"
is_read: bool (default False)
created_at: datetime
```
**Backend endpoints:**
```
GET /api/v1/notifications — List notifications (paginated, unread first)
GET /api/v1/notifications/unread-count — Unread count (for badge)
PATCH /api/v1/notifications/{id}/read — Mark as read
POST /api/v1/notifications/mark-all-read — Mark all as read
```
**Frontend changes to `NotificationsPanel.tsx`:**
- Replace `sessionsApi.list()` with notifications API
- Show unread count badge on the bell icon (red dot → count badge when > 0)
- Each notification item: icon (by event type), title, body, time ago, link
- Click a notification → mark as read + navigate to the link
- "Mark all as read" button in the dropdown header
- Keep existing glass-card dropdown styling
**Wire into notification service:** When `notify()` fires for email/Slack/Teams, also create a `Notification` row for each target user. In-app notifications are always created regardless of channel config.
**Verification:** Escalate a session. Check the bell icon shows a badge. Open dropdown — see the escalation notification. Click it — navigate to the session. Badge clears.
```
git commit -m "feat(notifications): add in-app notification center extending existing NotificationsPanel"
```
---
## Slice 3: Session Export & Sharing — MOSTLY COMPLETE (polish only)
> **Status:** `export_service.py` (1145 lines, 5 formats), frontend export UI on `SessionDetailPage` (format selection, preview, clipboard, download, redaction), and WeasyPrint are all implemented. Remaining: verify PDF template styling, add loading spinner for PDF generation, ensure "Generated with ResolutionFlow" branding on all formats, end-to-end test all formats.
### Task 7: Build session export service
**Files:**
- Create: `backend/app/services/session_export_service.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
**Export formats:**
**1. PDF export:**
- Professional PDF with ResolutionFlow branding
- Sections: Problem Summary, Diagnostic Trail, Resolution/Escalation, Session Metadata
- Each step formatted cleanly with numbered steps, responses, outcomes
- "Generated with ResolutionFlow" footer + URL on every page
- Redact sensitive data (passwords) using existing `redaction_service.py`
**2. Markdown export:**
- Clean markdown version of the session documentation
- Suitable for pasting into wikis, Confluence, SharePoint, etc.
- Same structure as PDF but in markdown format
**3. Shareable link:**
- Generate a time-limited shareable link (24h default, configurable)
- Reuse the existing `SessionShare` model pattern from legacy sessions
- Public view shows the session in read-only mode with minimal UI
- "Generated with ResolutionFlow — Start your free trial" CTA at the bottom
**New endpoints:**
```
GET /api/v1/ai-sessions/{id}/export?format=pdf — Download PDF
GET /api/v1/ai-sessions/{id}/export?format=markdown — Download markdown
POST /api/v1/ai-sessions/{id}/share — Create shareable link
GET /api/v1/shared/ai-session/{token} — Public view (no auth)
```
**PDF generation:** Use `weasyprint` — it produces the best output for HTML→PDF conversion. Requires system-level dependencies in the Dockerfile:
```dockerfile
# Add to backend/Dockerfile before pip install
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev \
&& rm -rf /var/lib/apt/lists/*
```
Generate a styled HTML template (with ResolutionFlow branding, fonts, colors) and pass it to `weasyprint.HTML(string=html).write_pdf()`. Keep the HTML template in `backend/app/templates/session_export.html`.
**Key detail — growth mechanic:** Every export and shared link includes "Generated with ResolutionFlow" branding. This is the viral growth loop identified in the Product Strategy Review. Make the branding tasteful but visible — a small footer, not an intrusive watermark.
**Verification:** Resolve a session. Export as PDF — verify clean formatting with branding. Export as markdown — verify proper markdown. Create a share link — open in incognito — verify read-only view with CTA.
```
git commit -m "feat(export): add session export (PDF, markdown, shareable link)"
```
### Task 8: Export and share buttons in frontend
**Files:**
- Create: `frontend/src/components/flowpilot/SessionExportMenu.tsx`
- Edit: `frontend/src/components/flowpilot/SessionDocView.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
**What to add:**
An export menu in the session documentation view and completed session view:
- "Export" dropdown button with options: "Download PDF", "Download Markdown", "Copy to Clipboard" (formatted text via `navigator.clipboard.writeText()` — most-used option for pasting into PSA ticket notes, wikis, or emails)
- "Share" button → generates shareable link → copies to clipboard with toast notification
- Share link shows expiration time and option to revoke
**Design:** Small dropdown menu, consistent with existing UI patterns. Export icon from Lucide (`Download` or `Share2`).
**Verification:** Complete a session. Click Export → Download PDF. Verify PDF downloads. Click Share → verify link is copied. Open link in incognito.
```
git commit -m "feat(export): add export and share UI"
```
---
## Slice 4: Mobile/Responsive Polish
### Task 9: Responsive design pass for FlowPilot session
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotIntake.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotStepCard.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotOptions.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotActionBar.tsx`
- Edit: `frontend/src/components/flowpilot/ConfidenceIndicator.tsx`
- Edit: `frontend/src/components/flowpilot/SessionDocView.tsx`
- Edit: `frontend/src/components/flowpilot/EscalateModal.tsx`
- Edit: `frontend/src/components/flowpilot/EscalationQueue.tsx`
- Edit: `frontend/src/components/flowpilot/SessionTicketCard.tsx`
- Edit: `frontend/src/components/flowpilot/InSessionScriptGenerator.tsx`
- Edit: `frontend/src/pages/ReviewQueuePage.tsx`
- Edit: `frontend/src/pages/FlowPilotAnalyticsPage.tsx`
- Edit: `frontend/src/pages/PublicTemplatesPage.tsx`
**This is NOT a rebuild. It's a responsive audit and fix pass.**
**Breakpoints (follow existing Tailwind v4 breakpoints):**
- Mobile: < 640px (`sm:`)
- Tablet: 640px - 1024px (`md:`)
- Desktop: > 1024px (`lg:`)
**Key responsive changes:**
**FlowPilot Session (most critical):**
- Desktop: Two-column layout (conversation 70% + sidebar 30%)
- Tablet: Sidebar collapses to a top bar with key info (problem summary, confidence, ticket #). Expandable on tap.
- Mobile: Single column. Sidebar info moves to a collapsible header. Action bar stays fixed at bottom.
**Options Grid:**
- Desktop: 2-column grid
- Tablet: 2-column grid (slightly narrower cards)
- Mobile: Single column stack
**Step Cards:**
- Desktop/Tablet: Full width with padding
- Mobile: Edge-to-edge cards with reduced padding
**Action Bar (Resolve/Escalate):**
- All sizes: Fixed to bottom of viewport. Buttons full-width on mobile.
**Intake Screen:**
- All sizes: Already mostly responsive (centered card). Ensure textarea and buttons are full-width on mobile.
**Modals (Resolve, Escalate, Ticket Picker):**
- Desktop: Centered modal with max-width
- Mobile: Full-screen slide-up panel built with Tailwind (`fixed inset-x-0 bottom-0 h-[90vh] rounded-t-2xl` with `translate-y` animation), or full-width modal matching existing modal patterns
**Script Generator (in-session):**
- Desktop: Inline with side-by-side form + preview
- Mobile: Stacked — form on top, preview below. Preview uses horizontal scroll for long script lines.
**Review Queue:**
- Desktop: Two-panel (list + detail)
- Mobile: List only, tap to navigate to detail page
**Analytics Dashboard:**
- Desktop: Multi-column chart grid
- Mobile: Single column, charts stack vertically. Charts resize to viewport width.
**Public Templates Gallery:**
- Desktop: 3-column card grid
- Tablet: 2-column
- Mobile: Single column
**Testing approach:**
- Use Chrome DevTools device emulation for iPhone 14 Pro (390px), iPad (810px), and desktop (1440px)
- Test every FlowPilot page at each breakpoint
- Ensure no horizontal overflow, no text truncation that hides critical info, all buttons are tappable (minimum 44px touch targets)
**Verification:** Open FlowPilot on Chrome DevTools mobile emulation. Start a session. Progress through steps. Resolve. Verify the entire flow is usable on a phone-sized screen. Repeat on tablet size.
```
git commit -m "feat(responsive): mobile/tablet responsive pass for all FlowPilot pages"
```
---
## Slice 5: Enterprise Readiness Foundations
### Task 10: Custom branding system
**Files:**
- Edit: `backend/app/models/account.py` (or use existing branding model if it exists)
- Edit: `backend/app/api/endpoints/branding.py` (existing endpoint)
- Edit: `frontend/src/components/layout/AppLayout.tsx`
- Edit: `frontend/src/styles/` (CSS variable overrides)
**What to add:**
Check if branding infrastructure already exists (there's a `branding.py` endpoint and the Product Strategy mentions custom branding for Enterprise). If it does, extend it. If not, build:
- Account-level branding settings: logo URL, primary color, sidebar color, company name
- CSS variable overrides applied at the layout level via inline `style` on the app shell root. Target the exact Tailwind v4 theme variables from `index.css`:
- `--color-primary` (oklch — main accent, currently cyan `oklch(0.65 0.13 195)`)
- `--color-primary-foreground` (text on primary backgrounds)
- `--glass-bg` (card/panel backgrounds)
- Convert the customer's hex color to oklch using a utility function before applying
- Logo appears in the sidebar header, replacing the ResolutionFlow logo
- Company name appears in session exports and shared links
- Branding settings page in Account Settings (owner-only)
**Keep it simple for v1:** Just logo + primary accent color + company name. Full theme customization can come later.
**Verification:** Upload a logo and set a custom primary color. Verify the sidebar shows the custom logo. Verify the accent color changes throughout the app. Export a session — verify company name appears.
```
git commit -m "feat(enterprise): add custom branding system"
```
### Task 11: Multi-PSA adapter stubs
**Files:**
- Create: `backend/app/services/psa/autotask/` directory with `__init__.py`, `provider.py`, `client.py`
- Create: `backend/app/services/psa/halopsa/` directory with `__init__.py`, `provider.py`, `client.py`
- Edit: `backend/app/services/psa/registry.py`
**What to add:**
Create stub implementations for Autotask and Halo PSA that extend the existing `PSAProvider` abstract base class. These stubs should:
- Implement all abstract methods
- Raise `NotImplementedError("Autotask integration coming soon")` for each method
- Include docstrings noting the expected API endpoints and authentication patterns
- Register in the PSA registry so the provider selection dropdown shows them as "Coming Soon"
**Why stubs now:** This establishes the multi-PSA architecture so when it's time to fully implement Autotask or Halo, the structure is ready. It also signals to enterprise prospects that multi-PSA is on the roadmap.
**Frontend change:** In the PSA connection setup UI, show Autotask and Halo PSA as options with a "Coming Soon" badge. They should be visible but not selectable.
**Verification:** Open the integrations page. See ConnectWise (active), Autotask (Coming Soon badge), Halo PSA (Coming Soon badge).
```
git commit -m "feat(enterprise): add multi-PSA adapter stubs for Autotask and Halo"
```
### Task 12: SSO/SAML groundwork
**Files:**
- Create: `backend/app/services/sso_service.py` (stub)
- Edit: `backend/app/models/account.py`
**What to add:**
This is groundwork only — NOT a full SSO implementation. Add:
- `sso_enabled` boolean on the Account model (default false)
- `sso_provider` string on Account (nullable — "saml" | "oidc" | null)
- `sso_config` JSONB on Account (nullable — will hold IdP metadata URL, entity ID, etc.)
- A stub `sso_service.py` with the expected interface:
```python
async def initiate_sso_login(account_slug: str) -> str: ... # Returns redirect URL
async def process_sso_callback(saml_response: str) -> User: ... # Returns authenticated user
async def validate_sso_config(config: dict) -> bool: ... # Tests IdP connectivity
```
- Migration for the new columns
**Do NOT implement the actual SAML/OIDC flow.** This just establishes the model and service interface so when an enterprise customer needs it, the architecture is ready and the feature flag can be checked.
**Frontend:** In Account Settings, show an "SSO / Single Sign-On" section with a "Contact us to enable SSO for your organization" message. This acts as a lead capture for enterprise sales conversations.
**Verification:** Check the account model has SSO fields. Verify the settings page shows the SSO section with the contact message.
```
git commit -m "feat(enterprise): add SSO/SAML groundwork (model + stub service)"
```
---
## Summary of All New/Modified Files
### Backend — New
```
app/api/endpoints/public_templates.py # Public gallery API
app/schemas/public_templates.py # Public gallery schemas
app/services/notification_service.py # Event-driven notifications (with retry via APScheduler)
app/models/notification_config.py # Notification channel configs
app/models/notification_log.py # Notification delivery log (retry tracking)
app/models/notification.py # In-app notification model
app/api/endpoints/notifications.py # In-app notification endpoints
app/schemas/notification.py # Notification schemas
app/templates/session_export.html # Styled HTML template for PDF export
app/services/session_export_service.py # PDF/markdown/share export
app/services/psa/autotask/__init__.py # Autotask stub
app/services/psa/autotask/provider.py # Autotask provider stub
app/services/psa/autotask/client.py # Autotask client stub
app/services/psa/halopsa/__init__.py # Halo PSA stub
app/services/psa/halopsa/provider.py # Halo PSA provider stub
app/services/psa/halopsa/client.py # Halo PSA client stub
app/services/sso_service.py # SSO stub service
alembic/versions/xxx_phase4_growth.py # Migration
```
### Backend — Modified
```
app/api/router.py # Register new routers
app/api/endpoints/ai_sessions.py # Export + share endpoints
app/api/endpoints/branding.py # Custom branding extensions
app/services/flowpilot_engine.py # Notification calls on escalation/high-priority
app/services/knowledge_flywheel.py # Notification calls on proposal creation
app/api/endpoints/flow_proposals.py # Notification calls on approval
app/services/psa/registry.py # Register Autotask + Halo stubs
app/models/account.py # SSO fields
app/models/tree.py # is_gallery_featured, gallery_sort_order
app/models/script_template.py # is_gallery_featured, gallery_sort_order
```
### Frontend — New
```
src/pages/PublicTemplatesPage.tsx # Public gallery page
src/components/public/TemplateGalleryGrid.tsx # Gallery grid layout
src/components/public/FlowTemplateCard.tsx # Flow card
src/components/public/ScriptTemplateCard.tsx # Script card
src/components/public/TemplateDetailModal.tsx # Preview modal with signup CTA
src/components/public/GallerySearch.tsx # Search component
src/components/flowpilot/SessionExportMenu.tsx # Export dropdown
src/components/account/NotificationSettings.tsx # Notification channel config
src/api/publicTemplates.ts # Public gallery API client
src/api/notifications.ts # Notifications API client
src/types/public-templates.ts # Public gallery types
src/types/notification.ts # Notification types
```
### Frontend — Modified (responsive pass)
```
src/components/flowpilot/FlowPilotIntake.tsx
src/components/flowpilot/FlowPilotSession.tsx
src/components/flowpilot/FlowPilotStepCard.tsx
src/components/flowpilot/FlowPilotOptions.tsx
src/components/flowpilot/FlowPilotActionBar.tsx
src/components/flowpilot/ConfidenceIndicator.tsx
src/components/flowpilot/SessionDocView.tsx
src/components/flowpilot/EscalateModal.tsx
src/components/flowpilot/EscalationQueue.tsx
src/components/flowpilot/SessionTicketCard.tsx
src/components/flowpilot/InSessionScriptGenerator.tsx
src/pages/ReviewQueuePage.tsx
src/pages/FlowPilotAnalyticsPage.tsx
src/pages/PublicTemplatesPage.tsx
src/router.tsx # Public gallery route + any new routes
src/components/sidebar/ # Updated with any new nav entries
src/components/layout/AppLayout.tsx # Custom branding support
src/pages/account/IntegrationsPage.tsx # Notification settings + PSA stubs
```
---
## Database Changes
**Migration:** Single migration with multiple changes:
```python
# 1. Gallery featuring columns
op.add_column('trees', sa.Column('is_gallery_featured', sa.Boolean(), nullable=True, server_default='false'))
op.add_column('trees', sa.Column('gallery_sort_order', sa.Integer(), nullable=True, server_default='0'))
op.add_column('script_templates', sa.Column('is_gallery_featured', sa.Boolean(), nullable=True, server_default='false'))
op.add_column('script_templates', sa.Column('gallery_sort_order', sa.Integer(), nullable=True, server_default='0'))
# 2. Notification configs table
op.create_table('notification_configs', ...)
# 3. Notification logs table (retry tracking)
op.create_table('notification_logs', ...)
# 4. In-app notifications table
op.create_table('notifications', ...)
# 5. SSO groundwork on accounts
op.add_column('accounts', sa.Column('sso_enabled', sa.Boolean(), nullable=True, server_default='false'))
op.add_column('accounts', sa.Column('sso_provider', sa.String(20), nullable=True))
op.add_column('accounts', sa.Column('sso_config', sa.dialects.postgresql.JSONB(), nullable=True))
```
**Run migration:**
```bash
cd /projects/patherly/backend
DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \
venv/bin/alembic upgrade head
```
---
## Testing Strategy
### Backend Tests
**Files:** `backend/tests/test_public_templates.py`
- Test gallery endpoint returns only featured templates
- Test no script bodies exposed
- Test no deep flow structures exposed
- Test search functionality
- Test unauthenticated access works
**Files:** `backend/tests/test_notification_service.py`
- Test event routing to correct channels
- Test Slack webhook format
- Test Teams webhook format
- Test email notification
- Test disabled events are not sent
**Files:** `backend/tests/test_session_export.py`
- Test PDF generation
- Test markdown generation
- Test shareable link creation and expiry
- Test branding in exports
### Frontend Manual Testing
1. Open `/templates` without login — browse gallery, search, filter
2. Click a template — see preview modal with signup CTA
3. Configure Slack notifications — escalate a session — verify Slack message
4. Export a session as PDF — verify clean formatting + branding
5. Share a session — open link in incognito — verify read-only view
6. Test all FlowPilot pages on mobile viewport (390px)
7. Test all FlowPilot pages on tablet viewport (810px)
8. Upload custom branding — verify it appears throughout the app
9. Check integrations page — verify Autotask and Halo show as "Coming Soon"
10. Escalate a session — verify bell icon badge appears — click notification — navigates to session
---
## Low Priority: Gallery Analytics Tracking
PostHog is already integrated. When building the public gallery (Slice 1), add these events using `analytics.ts` helpers:
- `gallery_viewed` — page load with active filters/category
- `template_clicked` — which template, flow vs script, position in grid
- `template_search` — search terms (valuable for understanding MSP needs)
- `signup_cta_clicked` — from gallery detail modal (conversion tracking)
This data reveals which templates drive signups and what topics to create more content around. Implement alongside Tasks 1-2, not as a separate task.
---
## What Comes Next (Phase 5+ — Future)
- **Full Autotask implementation:** Complete the PSA provider for Autotask PSA
- **Full Halo PSA implementation:** Complete the PSA provider for Halo PSA
- **Full SSO/SAML implementation:** Complete SAML + OIDC authentication flows
- **Native mobile app:** React Native or PWA for on-site troubleshooting
- **AI model selection per account:** Allow enterprise customers to choose model tier (haiku/sonnet/opus)
- **Webhook API:** Public webhooks for third-party integrations
- **ConnectWise Marketplace listing:** Package and submit to CW marketplace
- **White-label option:** Full branding removal for enterprise resellers

View File

@@ -1,7 +1,7 @@
# Stack Priorities And Playwright Plan # Stack Priorities And Playwright Plan
> **Date:** 2026-03-16 > **Date:** 2026-03-16
> **Updated:** 2026-03-17 > **Updated:** 2026-03-18
> **Product:** ResolutionFlow > **Product:** ResolutionFlow
> **Purpose:** Turn the recent stack-gap review into a practical, sequenced execution plan > **Purpose:** Turn the recent stack-gap review into a practical, sequenced execution plan
@@ -17,8 +17,8 @@
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled (no gate yet) | | 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 | | 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 | | Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals library |
| Search and recall improvements | ⬜ Not started | | | 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 | ⬜ Not started | | | 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 | | | Smart PSA / client context | ⬜ Not started | |
| Queue / worker architecture | ⬜ Not started | | | Queue / worker architecture | ⬜ Not started | |
| Buyer-facing trust surfaces | ⬜ Not started | | | Buyer-facing trust surfaces | ⬜ Not started | |

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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More