feat: FlowPilot AI — Phases 4 & 5 (Gallery, Export, Responsive, Enterprise, Analytics) #116
47
CLAUDE.md
47
CLAUDE.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
222
CURRENT-STATE.md
222
CURRENT-STATE.md
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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")
|
||||||
@@ -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')
|
||||||
@@ -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',
|
||||||
|
)
|
||||||
@@ -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')
|
||||||
@@ -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')",
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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')
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
191
backend/app/api/endpoints/admin_gallery.py
Normal file
191
backend/app/api/endpoints/admin_gallery.py
Normal 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)
|
||||||
779
backend/app/api/endpoints/ai_sessions.py
Normal file
779
backend/app/api/endpoints/ai_sessions.py
Normal 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,
|
||||||
|
}
|
||||||
314
backend/app/api/endpoints/flow_proposals.py
Normal file
314
backend/app/api/endpoints/flow_proposals.py
Normal 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
|
||||||
729
backend/app/api/endpoints/flowpilot_analytics.py
Normal file
729
backend/app/api/endpoints/flowpilot_analytics.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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 ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
255
backend/app/api/endpoints/notifications.py
Normal file
255
backend/app/api/endpoints/notifications.py
Normal 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)
|
||||||
458
backend/app/api/endpoints/public_templates.py
Normal file
458
backend/app/api/endpoints/public_templates.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
208
backend/app/api/endpoints/uploads.py
Normal file
208
backend/app/api/endpoints/uploads.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
— ResolutionFlow
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
210
backend/app/models/ai_session.py
Normal file
210
backend/app/models/ai_session.py
Normal 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",
|
||||||
|
)
|
||||||
53
backend/app/models/ai_session_embedding.py
Normal file
53
backend/app/models/ai_session_embedding.py
Normal 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),
|
||||||
|
)
|
||||||
133
backend/app/models/ai_session_step.py
Normal file
133
backend/app/models/ai_session_step.py
Normal 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")
|
||||||
32
backend/app/models/file_upload.py
Normal file
32
backend/app/models/file_upload.py
Normal 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)
|
||||||
|
)
|
||||||
152
backend/app/models/flow_proposal.py
Normal file
152
backend/app/models/flow_proposal.py
Normal 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")
|
||||||
45
backend/app/models/notification.py
Normal file
45
backend/app/models/notification.py
Normal 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])
|
||||||
60
backend/app/models/notification_config.py
Normal file
60
backend/app/models/notification_config.py
Normal 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])
|
||||||
52
backend/app/models/notification_log.py
Normal file
52
backend/app/models/notification_log.py
Normal 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]
|
||||||
|
)
|
||||||
28
backend/app/models/psa_activity_log.py
Normal file
28
backend/app/models/psa_activity_log.py
Normal 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)
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
203
backend/app/schemas/ai_session.py
Normal file
203
backend/app/schemas/ai_session.py
Normal 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}
|
||||||
51
backend/app/schemas/flow_proposal.py
Normal file
51
backend/app/schemas/flow_proposal.py
Normal 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}]
|
||||||
126
backend/app/schemas/flowpilot_analytics.py
Normal file
126
backend/app/schemas/flowpilot_analytics.py
Normal 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
|
||||||
85
backend/app/schemas/notification.py
Normal file
85
backend/app/schemas/notification.py
Normal 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
|
||||||
83
backend/app/schemas/public_templates.py
Normal file
83
backend/app/schemas/public_templates.py
Normal 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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
15
backend/app/schemas/upload.py
Normal file
15
backend/app/schemas/upload.py
Normal 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}
|
||||||
@@ -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"- ")
|
||||||
|
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"- ")
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
278
backend/app/services/flow_matching_engine.py
Normal file
278
backend/app/services/flow_matching_engine.py
Normal 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
|
||||||
1238
backend/app/services/flowpilot_engine.py
Normal file
1238
backend/app/services/flowpilot_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
454
backend/app/services/knowledge_flywheel.py
Normal file
454
backend/app/services/knowledge_flywheel.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
72
backend/app/services/knowledge_flywheel_scheduler.py
Normal file
72
backend/app/services/knowledge_flywheel_scheduler.py
Normal 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)
|
||||||
334
backend/app/services/knowledge_gap_service.py
Normal file
334
backend/app/services/knowledge_gap_service.py
Normal 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
|
||||||
39
backend/app/services/llm_utils.py
Normal file
39
backend/app/services/llm_utils.py
Normal 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
|
||||||
420
backend/app/services/notification_service.py
Normal file
420
backend/app/services/notification_service.py
Normal 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
|
||||||
3
backend/app/services/psa/autotask/__init__.py
Normal file
3
backend/app/services/psa/autotask/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.services.psa.autotask.provider import AutotaskProvider
|
||||||
|
|
||||||
|
__all__ = ["AutotaskProvider"]
|
||||||
72
backend/app/services/psa/autotask/provider.py
Normal file
72
backend/app/services/psa/autotask/provider.py
Normal 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")
|
||||||
@@ -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:
|
||||||
|
...
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
3
backend/app/services/psa/halopsa/__init__.py
Normal file
3
backend/app/services/psa/halopsa/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.services.psa.halopsa.provider import HaloPSAProvider
|
||||||
|
|
||||||
|
__all__ = ["HaloPSAProvider"]
|
||||||
72
backend/app/services/psa/halopsa/provider.py
Normal file
72
backend/app/services/psa/halopsa/provider.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
432
backend/app/services/psa_documentation_service.py
Normal file
432
backend/app/services/psa_documentation_service.py
Normal 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
|
||||||
52
backend/app/services/psa_retry_scheduler.py
Normal file
52
backend/app/services/psa_retry_scheduler.py
Normal 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()
|
||||||
165
backend/app/services/session_embedding_service.py
Normal file
165
backend/app/services/session_embedding_service.py
Normal 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
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
26
backend/app/services/sso_service.py
Normal file
26
backend/app/services/sso_service.py
Normal 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")
|
||||||
86
backend/app/services/storage_service.py
Normal file
86
backend/app/services/storage_service.py
Normal 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}")
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
312
backend/tests/test_admin_gallery.py
Normal file
312
backend/tests/test_admin_gallery.py
Normal 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
|
||||||
680
backend/tests/test_analytics_phase5.py
Normal file
680
backend/tests/test_analytics_phase5.py
Normal 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)
|
||||||
363
backend/tests/test_public_templates.py
Normal file
363
backend/tests/test_public_templates.py
Normal 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
|
||||||
301
backend/tests/test_uploads.py
Normal file
301
backend/tests/test_uploads.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
914
docs/2026-03-18-flowpilot-first-pivot-phase3.md
Normal file
914
docs/2026-03-18-flowpilot-first-pivot-phase3.md
Normal 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
|
||||||
828
docs/2026-03-18-flowpilot-first-pivot-phase4.md
Normal file
828
docs/2026-03-18-flowpilot-first-pivot-phase4.md
Normal 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
|
||||||
@@ -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 | |
|
||||||
|
|||||||
1204
docs/plans/2026-03-18-flowpilot-first-pivot-phase1.md
Normal file
1204
docs/plans/2026-03-18-flowpilot-first-pivot-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
885
docs/plans/2026-03-18-flowpilot-first-pivot-phase2.md
Normal file
885
docs/plans/2026-03-18-flowpilot-first-pivot-phase2.md
Normal 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)
|
||||||
1136
docs/plans/2026-03-19-phase4-remaining-slices-impl.md
Normal file
1136
docs/plans/2026-03-19-phase4-remaining-slices-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
1570
docs/plans/2026-03-19-phase4-slice2-notifications.md
Normal file
1570
docs/plans/2026-03-19-phase4-slice2-notifications.md
Normal file
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
Reference in New Issue
Block a user