diff --git a/CLAUDE.md b/CLAUDE.md index 1da4cc4e..7b26ee17 100644 --- a/CLAUDE.md +++ b/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 - **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) -- **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) - **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. @@ -62,9 +62,11 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie ### What's In Progress - 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 +- FlowPilot Phase 2: PSA integration, escalation handoff, session pause/resume, mid-session ticket linking - Step Library Foundation - 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 @@ -91,7 +93,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie ### Frontend - **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) - **Routing:** React Router v7 - **API Client:** Axios with token refresh interceptor @@ -107,24 +109,29 @@ patherly/ │ ├── app/ │ │ ├── main.py # FastAPI entry point │ │ ├── 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 │ │ ├── core/ # config, database, permissions, security, audit, rate_limit -│ │ ├── models/ # SQLAlchemy models +│ │ ├── models/ # SQLAlchemy models (includes FlowProposal) │ │ ├── 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+) │ ├── scripts/ # seed_data.py, seed_trees.py │ └── tests/ # pytest integration tests ├── frontend/ │ ├── src/ │ │ ├── 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 │ │ ├── pages/ # All page components │ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences) │ │ └── 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) ├── CLAUDE.md # This file ├── 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 - `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 - 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 @@ -313,6 +320,28 @@ gh run view --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. +**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 @@ -353,6 +382,8 @@ gh run view --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. - **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`. +- **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. --- diff --git a/CURRENT-STATE.md b/CURRENT-STATE.md index 2008e8a1..e386490e 100644 --- a/CURRENT-STATE.md +++ b/CURRENT-STATE.md @@ -2,19 +2,19 @@ > **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. -> **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 -### Backend (100%) -- FastAPI project structure with 25+ API endpoints -- PostgreSQL database with Docker, 30+ Alembic migrations +### Core Platform +- FastAPI project structure with 35+ API endpoints +- PostgreSQL database with Docker, 75+ Alembic migrations - User authentication (JWT, register, login, refresh, logout, invite codes) - Refresh token rotation with JTI-based revocation - Trees CRUD with full-text search (FTS index) @@ -27,32 +27,127 @@ - Audit log table with JSONB details - Soft delete for trees with cascade cleanup -### Frontend (Phase 2 Complete) -- React 19 + Vite + TypeScript + Tailwind setup -- Authentication UI (login, register) +### Frontend Core +- React 19 + Vite + TypeScript + Tailwind CSS v4 (`@tailwindcss/vite`) +- **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 navigation interface (session player) - Session management with history and detail pages -- Export functionality (download) - **Tree Editor** — Form-based with visual preview, Zustand + immer + zundo (undo/redo) - **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 -- **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 - **Admin Panel** — 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories) -- **Session Quick Wins** (Issues #51-#55): - - Session timer (`useSessionTimer` hook, MM:SS / HH:MM:SS) - - Keyboard hints (Tab focuses notes) - - Repeat Last Session (prefills metadata from localStorage) - - Session auto-recovery (resume incomplete sessions) - - Copy step to clipboard - - Delete tree button in all view modes -- **Session Outcomes** — Outcome modal on session completion, step timing tracking -- **Settings page** at `/settings` — Default export format preference -- **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) -- **Type-aware Routing** — Centralized `getTreeNavigatePath` helper, procedural sessions route to `/flows/:id/navigate`, resume support in procedural navigator, safety redirect in troubleshooting navigator +- **Session Quick Wins** — Timer, keyboard hints, repeat last, auto-recovery, copy step, delete tree +- **Session Outcomes** — Outcome modal on completion, step timing tracking +- **Session Sharing** — Share links, public/account views, MySharesPage +- **Procedural Editor UX** — Section headers, collapsible advanced fields, URL intake, tag input +- **Type-aware Routing** — Centralized `getTreeNavigatePath`/`getTreeEditorPath` helpers +- **Account Management** — Profile settings, delete/leave/transfer, chat retention +- **PostHog Analytics** — Event tracking, user identification, autocapture + +### FlowPilot AI System (Phases 1-3 Complete) + +**Phase 1 — AI Session Engine:** +- 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) - Registration role hardcoded to `engineer` @@ -63,72 +158,76 @@ - Centralized permissions in `permissions.py` - `is_active` field on User model, enforced in auth - Admin user management endpoints (6 endpoints) -- Refresh token rotation with JTI-based revocation - Password complexity validation (uppercase, lowercase, digit, min 10 chars) - Soft delete cascade cleanup (folder/tag junctions) - SQL wildcard escaping in tag search +- PSA credentials encrypted at rest (Fernet) -### Backend Schema Features (Not Yet in Frontend) -- **Tree Forking** (migration 022) — `parent_tree_id`, `root_tree_id`, `fork_depth`, `fork_reason` -- **Tree Sharing** (migration 024) — tree share links -- **Enhanced Invite Codes** (migration 030) — email, assigned_plan, trial_duration_days +### Maintenance Flows +- Batch session launch, saved target lists +- APScheduler scheduling with croniter + pytz + +### 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 -- CLAUDE.md (project context for Claude Code) -- CLAUDE.md includes consolidated lessons learned (formerly LESSONS-LEARNED.md) -- Design system guide, component examples +- CLAUDE.md (comprehensive project context) +- UI-DESIGN-SYSTEM.md, REBRAND-IMPLEMENTATION-GUIDE.md +- ConnectWise API reference docs in `docs/connectwise/` - Feature specifications through Phase 4 -- Rebrand implementation guide +- Phase implementation plans in `docs/plans/` --- ## What's In Progress -| Task | Status | Notes | -|------|--------|-------| -| Editor-Embedded Flow Assist | In Progress | AI panel in tree + procedural editors, ghost node suggestions, action-type routing | -| Step Library Frontend | In Progress | Backend complete, frontend UI pending | -| Procedural Flows Lifecycle | In Progress | Resume support done, full run chooser/reuse pending | -| Tree Forking UI | Planning | Backend schema complete (migration 022) | +No active slices — Phase 5 complete. --- ## What's Next (Priority Order) -### Immediate (Phase 2.5 Completion) -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 6+) -### Soon (Phase 3) -- File attachments for sessions -- Offline capability -- Client context system -- Advanced analytics dashboard - -### Later (Phase 4) -- PSA integrations (ConnectWise, Kaseya) +- Full Autotask PSA implementation +- Full Halo PSA implementation +- Full SSO/SAML implementation (SAML + OIDC flows) +- Dedicated Insights dashboard (strategic metrics for team leads, separate from operational analytics) - 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 ### Start Development -```powershell -docker start patherly_postgres -cd backend && .\venv\Scripts\activate && uvicorn app.main:app --reload -cd frontend && npm run dev +```bash +# Start PostgreSQL (Docker Compose) +docker compose up -d + +# Backend (from backend/) +source venv/bin/activate +uvicorn app.main:app --reload + +# Frontend (from frontend/) +npm run dev ``` ### URLs -- Frontend: http://localhost:5173 -- Backend API: http://localhost:8000 -- API Docs: http://localhost:8000/api/docs +- Frontend: http://192.168.0.9:5173 +- Backend API: http://192.168.0.9:8000 +- API Docs: http://192.168.0.9:8000/api/docs ### Run Tests -```powershell +```bash cd backend && pytest --override-ini="addopts=" ``` @@ -138,5 +237,6 @@ cd backend && pytest --override-ini="addopts=" | Issue | Workaround | Status | |-------|------------|--------| -| pytest-asyncio version conflict | Use 0.24.0 | Documented | -| No local psql on Windows | Use `docker exec` | Documented | +| `analysis_status` has no CheckConstraint | Valid values documented in code comments | Low priority | +| 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 | diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 64558e24..069c808e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # 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_member_mapping import PsaMemberMapping # noqa: F401 from app.core.config import settings diff --git a/backend/alembic/versions/3266dd9d8111_add_ai_session_id_to_script_generations.py b/backend/alembic/versions/3266dd9d8111_add_ai_session_id_to_script_generations.py new file mode 100644 index 00000000..9d60eda5 --- /dev/null +++ b/backend/alembic/versions/3266dd9d8111_add_ai_session_id_to_script_generations.py @@ -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') diff --git a/backend/alembic/versions/47c3b4f42e88_add_flow_proposals_table.py b/backend/alembic/versions/47c3b4f42e88_add_flow_proposals_table.py new file mode 100644 index 00000000..5ce48d9f --- /dev/null +++ b/backend/alembic/versions/47c3b4f42e88_add_flow_proposals_table.py @@ -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') diff --git a/backend/alembic/versions/49150866ae44_add_file_uploads_table.py b/backend/alembic/versions/49150866ae44_add_file_uploads_table.py new file mode 100644 index 00000000..8bce6d69 --- /dev/null +++ b/backend/alembic/versions/49150866ae44_add_file_uploads_table.py @@ -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') diff --git a/backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py b/backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py new file mode 100644 index 00000000..703be694 --- /dev/null +++ b/backend/alembic/versions/58e3f27f3e8f_add_branding_and_sso_columns_to_accounts.py @@ -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') diff --git a/backend/alembic/versions/9094262a4be3_add_gallery_featuring_columns_to_trees_.py b/backend/alembic/versions/9094262a4be3_add_gallery_featuring_columns_to_trees_.py new file mode 100644 index 00000000..7f4ad683 --- /dev/null +++ b/backend/alembic/versions/9094262a4be3_add_gallery_featuring_columns_to_trees_.py @@ -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') diff --git a/backend/alembic/versions/a0b871cb0c5e_add_analysis_status_to_ai_sessions.py b/backend/alembic/versions/a0b871cb0c5e_add_analysis_status_to_ai_sessions.py new file mode 100644 index 00000000..4cbd7ad9 --- /dev/null +++ b/backend/alembic/versions/a0b871cb0c5e_add_analysis_status_to_ai_sessions.py @@ -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') diff --git a/backend/alembic/versions/a7c9e3b1f402_add_ai_session_embeddings_table.py b/backend/alembic/versions/a7c9e3b1f402_add_ai_session_embeddings_table.py new file mode 100644 index 00000000..1c9a6cdf --- /dev/null +++ b/backend/alembic/versions/a7c9e3b1f402_add_ai_session_embeddings_table.py @@ -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") diff --git a/backend/alembic/versions/b09c3789b7e6_add_notification_tables.py b/backend/alembic/versions/b09c3789b7e6_add_notification_tables.py new file mode 100644 index 00000000..464545db --- /dev/null +++ b/backend/alembic/versions/b09c3789b7e6_add_notification_tables.py @@ -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') diff --git a/backend/alembic/versions/b8d2f4a6c091_drop_file_uploads_session_id_fk.py b/backend/alembic/versions/b8d2f4a6c091_drop_file_uploads_session_id_fk.py new file mode 100644 index 00000000..82b8c674 --- /dev/null +++ b/backend/alembic/versions/b8d2f4a6c091_drop_file_uploads_session_id_fk.py @@ -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', + ) diff --git a/backend/alembic/versions/bb2101378a61_phase2_psa_flowpilot_integration.py b/backend/alembic/versions/bb2101378a61_phase2_psa_flowpilot_integration.py new file mode 100644 index 00000000..5605e0af --- /dev/null +++ b/backend/alembic/versions/bb2101378a61_phase2_psa_flowpilot_integration.py @@ -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') diff --git a/backend/alembic/versions/cc3201489b72_add_requesting_escalation_status.py b/backend/alembic/versions/cc3201489b72_add_requesting_escalation_status.py new file mode 100644 index 00000000..8c521394 --- /dev/null +++ b/backend/alembic/versions/cc3201489b72_add_requesting_escalation_status.py @@ -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')", + ) diff --git a/backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py b/backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py new file mode 100644 index 00000000..9d033b5e --- /dev/null +++ b/backend/alembic/versions/dbf67047d4c8_add_full_text_search_vector_to_ai_.py @@ -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") diff --git a/backend/alembic/versions/e0d382f083d4_add_flow_tracking_columns_and_psa_.py b/backend/alembic/versions/e0d382f083d4_add_flow_tracking_columns_and_psa_.py new file mode 100644 index 00000000..df106f97 --- /dev/null +++ b/backend/alembic/versions/e0d382f083d4_add_flow_tracking_columns_and_psa_.py @@ -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') diff --git a/backend/alembic/versions/f0aad74ea51b_widen_ai_sessions_status_to_varchar_30.py b/backend/alembic/versions/f0aad74ea51b_widen_ai_sessions_status_to_varchar_30.py new file mode 100644 index 00000000..364c1893 --- /dev/null +++ b/backend/alembic/versions/f0aad74ea51b_widen_ai_sessions_status_to_varchar_30.py @@ -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, + ) diff --git a/backend/alembic/versions/f1a2b3c4d5e6_add_ai_sessions_and_ai_session_steps.py b/backend/alembic/versions/f1a2b3c4d5e6_add_ai_sessions_and_ai_session_steps.py new file mode 100644 index 00000000..c98f8edf --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_add_ai_sessions_and_ai_session_steps.py @@ -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") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index f17858de..28536d68 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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( current_user: Annotated[User, Depends(get_current_active_user)] ) -> User: diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index 91bb7fa8..fd49ec48 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -465,3 +465,96 @@ async def delete_account( await db.commit() 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, + ) diff --git a/backend/app/api/endpoints/admin_gallery.py b/backend/app/api/endpoints/admin_gallery.py new file mode 100644 index 00000000..8292bfb4 --- /dev/null +++ b/backend/app/api/endpoints/admin_gallery.py @@ -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) diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py new file mode 100644 index 00000000..7fb7d519 --- /dev/null +++ b/backend/app/api/endpoints/ai_sessions.py @@ -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, + } diff --git a/backend/app/api/endpoints/flow_proposals.py b/backend/app/api/endpoints/flow_proposals.py new file mode 100644 index 00000000..226dae92 --- /dev/null +++ b/backend/app/api/endpoints/flow_proposals.py @@ -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 diff --git a/backend/app/api/endpoints/flowpilot_analytics.py b/backend/app/api/endpoints/flowpilot_analytics.py new file mode 100644 index 00000000..66a322bb --- /dev/null +++ b/backend/app/api/endpoints/flowpilot_analytics.py @@ -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, + ) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index ba78b93c..602de191 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -279,6 +279,69 @@ async def test_connection( 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 ────────────────────────── diff --git a/backend/app/api/endpoints/notifications.py b/backend/app/api/endpoints/notifications.py new file mode 100644 index 00000000..ba27f2b3 --- /dev/null +++ b/backend/app/api/endpoints/notifications.py @@ -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) diff --git a/backend/app/api/endpoints/public_templates.py b/backend/app/api/endpoints/public_templates.py new file mode 100644 index 00000000..d2426465 --- /dev/null +++ b/backend/app/api/endpoints/public_templates.py @@ -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, + } diff --git a/backend/app/api/endpoints/scripts.py b/backend/app/api/endpoints/scripts.py index 5f6ab8df..182f837e 100644 --- a/backend/app/api/endpoints/scripts.py +++ b/backend/app/api/endpoints/scripts.py @@ -355,6 +355,7 @@ async def generate_script( user_id=current_user.id, team_id=current_user.team_id, session_id=data.session_id, + ai_session_id=data.ai_session_id, parameters_used=redacted_params, generated_script=rendered_script, ) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index ea4375f1..570b7672 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -424,18 +424,43 @@ async def export_session( 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 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" 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" 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" 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" # Resolve variables in export output diff --git a/backend/app/api/endpoints/sidebar.py b/backend/app/api/endpoints/sidebar.py index a473433b..40baadc7 100644 --- a/backend/app/api/endpoints/sidebar.py +++ b/backend/app/api/endpoints/sidebar.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db 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.tree import Tree from app.models.user import User @@ -146,6 +147,26 @@ async def get_sidebar_stats( 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_result = await db.execute( select( @@ -167,6 +188,7 @@ async def get_sidebar_stats( resolved_today=resolved_today, active_count=active_count, total_session_minutes_today=total_minutes, + escalation_count=escalation_count, tree_counts=SidebarTreeCounts( total=tc.total, troubleshooting=tc.troubleshooting, diff --git a/backend/app/api/endpoints/uploads.py b/backend/app/api/endpoints/uploads.py new file mode 100644 index 00000000..72f61c8a --- /dev/null +++ b/backend/app/api/endpoints/uploads.py @@ -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() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index e857bd20..884111fc 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -21,6 +21,13 @@ from app.api.endpoints import integrations from app.api.endpoints import onboarding from app.api.endpoints import branding 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() @@ -67,3 +74,10 @@ api_router.include_router(integrations.router) api_router.include_router(onboarding.router) api_router.include_router(branding.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) diff --git a/backend/app/core/ai_chat_service.py b/backend/app/core/ai_chat_service.py index c4c4ed75..8e1eab64 100644 --- a/backend/app/core/ai_chat_service.py +++ b/backend/app/core/ai_chat_service.py @@ -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}" -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 +from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences def _parse_delta(response: str) -> dict | None: diff --git a/backend/app/core/ai_fix_service.py b/backend/app/core/ai_fix_service.py index 02350a15..56325386 100644 --- a/backend/app/core/ai_fix_service.py +++ b/backend/app/core/ai_fix_service.py @@ -86,11 +86,7 @@ def _serialize_tree_outline( return "\n".join(lines) -def _strip_markdown_fences(text: str) -> str: - """Strip ```json...``` fences from AI response.""" - return re.sub(r"^```(?:json)?\s*\n?", "", text.strip(), flags=re.MULTILINE).rstrip( - "`" - ).strip() +from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences def _replace_node_in_tree( diff --git a/backend/app/core/ai_tree_generator_service.py b/backend/app/core/ai_tree_generator_service.py index bf560874..2463068f 100644 --- a/backend/app/core/ai_tree_generator_service.py +++ b/backend/app/core/ai_tree_generator_service.py @@ -13,6 +13,8 @@ import re import uuid 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.config import settings 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.""" -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: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ea17db51..955543ef 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -124,6 +124,13 @@ class Settings(BaseSettings): """Check if any AI provider is configured.""" 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 # 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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index cee22d64..45dc8288 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -27,6 +27,9 @@ async def get_db() -> AsyncSession: async with async_session_maker() as session: try: yield session + except Exception: + await session.rollback() + raise finally: await session.close() diff --git a/backend/app/core/email.py b/backend/app/core/email.py index 24c13dcb..313d5db0 100644 --- a/backend/app/core/email.py +++ b/backend/app/core/email.py @@ -484,6 +484,45 @@ class EmailService: logger.exception("Failed to send beta signup notification for %s", signup_email) 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 async def send_survey_invite_email( to_email: str, @@ -856,3 +895,49 @@ def _render_feedback_confirmation_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""" + + + View in ResolutionFlow + + """ + + return f""" + + + + +
+ + + + + {link_section} + +
+

ResolutionFlow

+
+

{safe_title}

+
+

{safe_body}

+
+

+ — ResolutionFlow +

+
+
+""" diff --git a/backend/app/core/kb_conversion_service.py b/backend/app/core/kb_conversion_service.py index ba28fdd2..9bb9edf8 100644 --- a/backend/app/core/kb_conversion_service.py +++ b/backend/app/core/kb_conversion_service.py @@ -24,13 +24,7 @@ COST_PER_INPUT_TOKEN = 3.0 / 1_000_000 COST_PER_OUTPUT_TOKEN = 15.0 / 1_000_000 -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 +from app.services.llm_utils import strip_markdown_fences as _strip_markdown_fences def _try_repair_json(text: str) -> dict | None: diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index be97e452..dcd88ca6 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -85,8 +85,21 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): exc_info=True ) - # Re-raise exception to be handled by FastAPI - raise + # Return a proper response so it flows through CORSMiddleware. + # 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): @@ -95,6 +108,11 @@ class ErrorLoggingMiddleware(BaseHTTPMiddleware): Ensures all exceptions are logged before being returned to the client, 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( @@ -114,5 +132,17 @@ class ErrorLoggingMiddleware(BaseHTTPMiddleware): exc_info=True ) - # Re-raise to let FastAPI handle the response - raise + # Return a proper response so it flows through CORSMiddleware. + # 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"}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index b5f310f3..a9ade25e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,6 +33,7 @@ from app.core.rate_limit import limiter from app.api.router import api_router 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.notification_service import retry_failed_notifications from app.core.service_account import ensure_service_account # Initialize logging configuration @@ -61,6 +62,14 @@ async def archive_stale_ai_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: """Set globals on a seed script module.""" mod.API_BASE_URL = api_url # type: ignore[attr-defined] @@ -180,6 +189,37 @@ async def lifespan(app: FastAPI): 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 seed_task = None if settings.SEED_ON_DEPLOY: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b7854b0b..a38cc391 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -36,10 +36,19 @@ from .survey_response import SurveyResponse from .survey_invite import SurveyInvite from .kb_import import KBImport, KBImportNode 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_post_log import PsaPostLog from .psa_member_mapping import PsaMemberMapping 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__ = [ "User", @@ -90,8 +99,17 @@ __all__ = [ "ScriptCategory", "ScriptTemplate", "ScriptGeneration", + "AISession", + "AISessionStep", "PsaConnection", "PsaPostLog", "PsaMemberMapping", "SessionSupportingData", + "FlowProposal", + "NotificationConfig", + "NotificationLog", + "Notification", + "PsaActivityLog", + "FileUpload", + "AISessionEmbedding", ] diff --git a/backend/app/models/account.py b/backend/app/models/account.py index 7792e9b8..78353111 100644 --- a/backend/app/models/account.py +++ b/backend/app/models/account.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer 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 if TYPE_CHECKING: @@ -44,6 +44,16 @@ class Account(Base): 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 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") diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py new file mode 100644 index 00000000..c2722df0 --- /dev/null +++ b/backend/app/models/ai_session.py @@ -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", + ) diff --git a/backend/app/models/ai_session_embedding.py b/backend/app/models/ai_session_embedding.py new file mode 100644 index 00000000..a7baa210 --- /dev/null +++ b/backend/app/models/ai_session_embedding.py @@ -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), + ) diff --git a/backend/app/models/ai_session_step.py b/backend/app/models/ai_session_step.py new file mode 100644 index 00000000..413f142c --- /dev/null +++ b/backend/app/models/ai_session_step.py @@ -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") diff --git a/backend/app/models/file_upload.py b/backend/app/models/file_upload.py new file mode 100644 index 00000000..6846b1a3 --- /dev/null +++ b/backend/app/models/file_upload.py @@ -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) + ) diff --git a/backend/app/models/flow_proposal.py b/backend/app/models/flow_proposal.py new file mode 100644 index 00000000..5450e249 --- /dev/null +++ b/backend/app/models/flow_proposal.py @@ -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") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 00000000..334ee9a9 --- /dev/null +++ b/backend/app/models/notification.py @@ -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]) diff --git a/backend/app/models/notification_config.py b/backend/app/models/notification_config.py new file mode 100644 index 00000000..993147af --- /dev/null +++ b/backend/app/models/notification_config.py @@ -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]) diff --git a/backend/app/models/notification_log.py b/backend/app/models/notification_log.py new file mode 100644 index 00000000..5ee4e932 --- /dev/null +++ b/backend/app/models/notification_log.py @@ -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] + ) diff --git a/backend/app/models/psa_activity_log.py b/backend/app/models/psa_activity_log.py new file mode 100644 index 00000000..6b4014be --- /dev/null +++ b/backend/app/models/psa_activity_log.py @@ -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) + ) diff --git a/backend/app/models/psa_connection.py b/backend/app/models/psa_connection.py index 8cdd609e..c56b26df 100644 --- a/backend/app/models/psa_connection.py +++ b/backend/app/models/psa_connection.py @@ -1,11 +1,11 @@ """PSA connection model — one per account.""" import uuid from datetime import datetime, timezone -from typing import Optional +from typing import Optional, Any from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey 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 @@ -43,6 +43,10 @@ class PsaConnection(Base): default=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 account = relationship("Account", back_populates="psa_connection") diff --git a/backend/app/models/psa_post_log.py b/backend/app/models/psa_post_log.py index 54372fe0..14697507 100644 --- a/backend/app/models/psa_post_log.py +++ b/backend/app/models/psa_post_log.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime, timezone 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.dialects.postgresql import UUID @@ -16,10 +16,18 @@ class PsaPostLog(Base): id: Mapped[uuid.UUID] = mapped_column( 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), 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, ) psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column( @@ -35,8 +43,16 @@ class PsaPostLog(Base): ) status: Mapped[str] = mapped_column( String(20), nullable=False - ) # 'success' or 'failed' + ) # 'success', 'failed', 'pending_retry' 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( String(100), nullable=True ) @@ -54,5 +70,6 @@ class PsaPostLog(Base): # Relationships 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]) user = relationship("User", foreign_keys=[posted_by]) diff --git a/backend/app/models/script_template.py b/backend/app/models/script_template.py index c90c2da7..84ddfb19 100644 --- a/backend/app/models/script_template.py +++ b/backend/app/models/script_template.py @@ -65,6 +65,8 @@ class ScriptTemplate(Base): version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) @@ -97,6 +99,10 @@ class ScriptGeneration(Base): session_id: Mapped[Optional[uuid.UUID]] = mapped_column( 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) generated_script: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index eb05576c..3557a158 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone 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.dialects.postgresql import UUID, JSONB from app.core.database import Base @@ -85,6 +85,8 @@ class Tree(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_public: 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( String(20), nullable=False, @@ -161,6 +163,25 @@ class Tree(Base): 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 author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py new file mode 100644 index 00000000..ff14b700 --- /dev/null +++ b/backend/app/schemas/ai_session.py @@ -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} diff --git a/backend/app/schemas/flow_proposal.py b/backend/app/schemas/flow_proposal.py new file mode 100644 index 00000000..ca324fb5 --- /dev/null +++ b/backend/app/schemas/flow_proposal.py @@ -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}] diff --git a/backend/app/schemas/flowpilot_analytics.py b/backend/app/schemas/flowpilot_analytics.py new file mode 100644 index 00000000..b3155283 --- /dev/null +++ b/backend/app/schemas/flowpilot_analytics.py @@ -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 diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 00000000..63b6bf9d --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/schemas/public_templates.py b/backend/app/schemas/public_templates.py new file mode 100644 index 00000000..2a185140 --- /dev/null +++ b/backend/app/schemas/public_templates.py @@ -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} diff --git a/backend/app/schemas/script_template.py b/backend/app/schemas/script_template.py index 1dbdc561..de6ec106 100644 --- a/backend/app/schemas/script_template.py +++ b/backend/app/schemas/script_template.py @@ -116,7 +116,8 @@ class ScriptTemplateDetail(ScriptTemplateListItem): class ScriptGenerateRequest(BaseModel): template_id: UUID 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): id: UUID diff --git a/backend/app/schemas/sidebar.py b/backend/app/schemas/sidebar.py index 95d92291..e0ac5753 100644 --- a/backend/app/schemas/sidebar.py +++ b/backend/app/schemas/sidebar.py @@ -40,6 +40,7 @@ class SidebarStatsResponse(BaseModel): resolved_today: int active_count: int total_session_minutes_today: int + escalation_count: int = 0 tree_counts: SidebarTreeCounts active_sessions: list[SidebarActiveSession] recent_completions: list[SidebarRecentSession] diff --git a/backend/app/schemas/upload.py b/backend/app/schemas/upload.py new file mode 100644 index 00000000..fae7ed88 --- /dev/null +++ b/backend/app/schemas/upload.py @@ -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} diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 7d3d8091..ce03cc7d 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -171,10 +171,10 @@ def _escape_markdown_table(value: str) -> str: 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.""" if _is_procedural_session(session): - return _generate_procedural_markdown(session, options) + return _generate_procedural_markdown(session, options, uploads=uploads) lines = [] outcome_label = _get_outcome_label(session) @@ -223,6 +223,21 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin lines.append("---") lines.append("") + # File upload evidence + if uploads: + lines.append("## Evidence") + lines.append("") + for upload in uploads: + name = upload["filename"] + url = upload["url"] + if upload.get("is_image"): + lines.append(f"- ![{name}]({url})") + else: + lines.append(f"- [{name}]({url})") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## Troubleshooting Steps") lines.append("") @@ -299,13 +314,17 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin lines.append(next_steps.strip()) lines.append("") + # Branding footer + lines.append("---") + lines.append("Generated with ResolutionFlow — https://resolutionflow.com") + 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.""" if _is_procedural_session(session): - return _generate_procedural_text(session, options) + return _generate_procedural_text(session, options, uploads=uploads) lines = [] 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("") + # 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("-" * 20) @@ -408,13 +434,18 @@ def generate_text_export(session: Session, options: SessionExport, supporting_da lines.append("-" * 20) lines.append(next_steps.strip()) + # Branding footer + lines.append("") + lines.append("---") + lines.append("Generated with ResolutionFlow — https://resolutionflow.com") + 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.""" 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")) outcome_label = _get_outcome_label(session) @@ -467,6 +498,19 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da html_parts.append('

Evidence / Reference

') html_parts.append(f'
{html.escape(scratchpad)}
') + # File upload evidence + if uploads: + html_parts.append('

Evidence

') + html_parts.append('
') + for upload in uploads: + name = html.escape(upload["filename"]) + url = html.escape(upload["url"]) + if upload.get("is_image"): + html_parts.append(f'{name}') + else: + html_parts.append(f'

{name}

') + html_parts.append('
') + html_parts.append('

Troubleshooting Steps

') decisions = session.decisions @@ -524,14 +568,18 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da html_parts.append('

Next Steps

') html_parts.append(f'
{html.escape(next_steps.strip())}
') + # Branding footer + html_parts.append('
') + html_parts.append('

Generated with ResolutionFlow — https://resolutionflow.com

') + html_parts.extend(['', '']) 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.""" if _is_procedural_session(session): - return _generate_procedural_psa(session, options) + return _generate_procedural_psa(session, options, uploads=uploads) lines = [] 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 '' 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) @@ -664,7 +724,7 @@ def _get_session_variables(session: Session) -> dict[str, str]: 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.""" lines = [] 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("") + # File upload evidence + if uploads: + lines.append("---") + lines.append("") + lines.append("## Evidence") + lines.append("") + for upload in uploads: + name = upload["filename"] + url = upload["url"] + if upload.get("is_image"): + lines.append(f"- ![{name}]({url})") + else: + lines.append(f"- [{name}]({url})") + lines.append("") + + # Branding footer + lines.append("---") + lines.append("Generated with ResolutionFlow — https://resolutionflow.com") + return "\n".join(lines) -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.""" lines = [] outcome_label = _get_outcome_label(session) @@ -802,10 +881,22 @@ def _generate_procedural_text(session: Session, options: SessionExport) -> str: lines.append("-" * 20) 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) -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.""" tree_name = html.escape(session.tree_snapshot.get("name", "Procedure")) outcome_label = _get_outcome_label(session) @@ -887,11 +978,28 @@ def _generate_procedural_html(session: Session, options: SessionExport) -> str: html_parts.append('

Notes

') html_parts.append(f'
{html.escape(outcome_notes.strip())}
') + # File upload evidence + if uploads: + html_parts.append('

Evidence

') + html_parts.append('
') + for upload in uploads: + name = html.escape(upload["filename"]) + url = html.escape(upload["url"]) + if upload.get("is_image"): + html_parts.append(f'{name}') + else: + html_parts.append(f'

{name}

') + html_parts.append('
') + + # Branding footer + html_parts.append('
') + html_parts.append('

Generated with ResolutionFlow — https://resolutionflow.com

') + html_parts.extend(['', '']) 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.""" lines = [] 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(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) @@ -1059,6 +1179,32 @@ async def generate_pdf_export(session: Session, options: SessionExport, db) -> b 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 duration = _format_duration(session.started_at, session.completed_at) 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, steps=steps, supporting_data=supporting_data, + uploads=uploads_for_export, generated_at=generated_at, ) diff --git a/backend/app/services/flow_matching_engine.py b/backend/app/services/flow_matching_engine.py new file mode 100644 index 00000000..2dbb8957 --- /dev/null +++ b/backend/app/services/flow_matching_engine.py @@ -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 diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py new file mode 100644 index 00000000..e97c36b9 --- /dev/null +++ b/backend/app/services/flowpilot_engine.py @@ -0,0 +1,1238 @@ +"""FlowPilot Engine — core LLM orchestration for AI troubleshooting sessions. + +Manages structured diagnostic conversations: intake analysis, step generation, +confidence tracking, and auto-documentation. All LLM responses are structured +JSON validated against known output shapes. +""" +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, or_ +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.tree import Tree +from app.schemas.ai_session import ( + AISessionCreateRequest, + AISessionCreateResponse, + AISessionStepResponse, + StepOptionSchema, + StepResponseRequest, + StepResponseResponse, + ResolveSessionRequest, + EscalateSessionRequest, + SessionCloseResponse, + SessionDocumentation, + DocumentationStep, +) + +logger = logging.getLogger(__name__) + +# Maximum steps per session as a safety limit +MAX_STEPS_PER_SESSION = 30 + +STRUCTURED_OUTPUT_SCHEMA = """\ +Your response MUST be a valid JSON object with one of these shapes: + +1. Diagnostic question: +{"type": "question", "content": "Brief description", "reasoning": "Internal why", "context_message": "Shown to engineer", "options": [{"label": "Human text", "value": "machine_value", "followup_hint": "or null"}], "allow_free_text": true, "allow_skip": true, "confidence": 0.65} + +2. Suggested action: +{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request", "expected_outcome": "What success looks like", "confidence": 0.78} + +3. Resolution suggestion: +{"type": "resolution_suggestion", "content": "Summary of what we did", "reasoning": "Internal why", "resolution_summary": "Issue was caused by X, fixed by Y", "confidence": 0.92, "follow_up_recommendations": ["Monitor for 24 hours"]}\ +""" + +FLOWPILOT_SYSTEM_PROMPT = """\ +You are FlowPilot, an expert MSP troubleshooting assistant embedded in ResolutionFlow. You guide engineers through structured diagnosis of IT issues. + +## YOUR ROLE +- Conduct systematic troubleshooting through targeted questions and actions +- Start broad, narrow down based on responses +- Never guess — ask clarifying questions when uncertain +- Suggest specific, actionable steps the engineer can verify +- When confidence is high, suggest resolution; when low, keep investigating + +## RESPONSE FORMAT +You MUST respond with ONLY a valid JSON object. No markdown, no prose, no code fences. +Every response must have a "type" field: "question", "action", or "resolution_suggestion". + +{structured_output_schema} + +## RULES +- Maximum 5 options per question. Options should be the most likely scenarios. +- Always include relevant context in context_message — explain WHY you're asking +- confidence is a float 0.0-1.0 reflecting how certain you are about the diagnosis path +- When multiple symptoms point to one root cause with >90% confidence, suggest resolution +- If you detect the engineer needs a PowerShell script, suggest a script_generation action +- Never suggest restarting or rebooting as a first step — diagnose first +- Be specific: "Check Event Viewer > System > source NTFS" not "check the logs" + +{team_context} + +{matched_flow_context}\ +""" + +INTAKE_CLASSIFICATION_PROMPT = """\ +You are a triage classifier for IT support issues in an MSP environment. + +Analyze the following intake and respond with ONLY a JSON object: +{ + "problem_summary": "One-line summary of the issue (max 120 chars)", + "problem_domain": "One of: active_directory, networking, m365, hardware, endpoint, virtualization, security, backup, email, printing, cloud, other", + "key_symptoms": ["symptom1", "symptom2"], + "urgency": "low | medium | high | critical" +}\ +""" + + +def _confidence_to_tier(confidence: float) -> str: + """Map numeric confidence to tier label.""" + if confidence >= 0.8: + return "guided" + elif confidence >= 0.4: + return "exploring" + return "discovery" + + +def _parse_structured_output(raw_text: str) -> dict[str, Any]: + """Parse and validate structured JSON from LLM response. + + Uses shared parse_llm_json for fence stripping and JSON parsing, + then validates FlowPilot-specific output shape. + """ + data = parse_llm_json(raw_text) + + if not isinstance(data, dict) or "type" not in data: + raise ValueError("LLM response missing required 'type' field") + + valid_types = {"question", "action", "resolution_suggestion"} + if data["type"] not in valid_types: + raise ValueError(f"Unknown response type: {data['type']}") + + return data + + +def _build_step_response(step: AISessionStep, session: AISession) -> AISessionStepResponse: + """Convert a model step + session state into an API response.""" + 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 {} + return 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=session.confidence_score, + ) + + +async def start_session( + request: AISessionCreateRequest, + user_id: UUID, + account_id: UUID, + team_id: Optional[UUID], + db: AsyncSession, +) -> AISessionCreateResponse: + """Start a new FlowPilot session: classify intake, match flows, get first step.""" + + # 0. Process PSA ticket intake if applicable + ticket_context_block = None + ticket_data = None + psa_context_status = None + + if request.intake_type == "psa_ticket" and request.psa_connection_id and request.psa_ticket_id: + ticket_context_block, ticket_data, psa_context_status = await _process_ticket_intake( + psa_connection_id=request.psa_connection_id, + psa_ticket_id=request.psa_ticket_id, + db=db, + ) + # Enrich intake content with ticket context for classification + if ticket_data: + enriched_content = dict(request.intake_content) + enriched_content["ticket_data"] = { + "summary": ticket_data.get("ticket", {}).get("summary", ""), + "company": ticket_data.get("company", {}).get("name", ""), + "priority": ticket_data.get("ticket", {}).get("priority", ""), + } + request = request.model_copy(update={"intake_content": enriched_content}) + + # 1. Classify intake via fast LLM call + intake_text = _extract_intake_text(request.intake_content) + # Include ticket context in classification text if available + if ticket_context_block: + intake_text = f"{ticket_context_block}\n\n{intake_text}" + classification = await _classify_intake(intake_text) + + # 2. Try to match existing flows + from app.services.flow_matching_engine import find_matches + + matches = await find_matches( + intake_text=intake_text, + problem_domain=classification.get("problem_domain"), + account_id=account_id, + db=db, + ) + + top_match = matches[0] if matches else None + matched_flow_id = top_match["tree_id"] if top_match else None + match_score = top_match["score"] if top_match else None + matched_flow_name = top_match["tree_name"] if top_match else None + + # 3. Build system prompt + matched_flow_context = "" + if top_match and top_match.get("score", 0) > 0.5: + matched_flow_context = ( + f"## MATCHED FLOW\n" + f"A similar flow exists: \"{top_match['tree_name']}\" " + f"(match score: {top_match['score']:.0%}). " + f"Use it as a guide but adapt to the specific situation." + ) + + # Include ticket context in system prompt if available + ticket_prompt_section = "" + if ticket_context_block: + ticket_prompt_section = f"\n## PSA TICKET CONTEXT\n{ticket_context_block}\n" + + # Include available script templates for in-session script generation + script_context = await _build_script_context(team_id, db) + if script_context: + ticket_prompt_section += f"\n{script_context}\n" + + system_prompt = FLOWPILOT_SYSTEM_PROMPT.format( + structured_output_schema=STRUCTURED_OUTPUT_SCHEMA, + team_context=ticket_prompt_section, + matched_flow_context=matched_flow_context, + ) + + # 4. Build first user message from intake + user_message = _format_intake_message(request.intake_content, classification) + + messages = [{"role": "user", "content": user_message}] + + # 5. Call LLM for first diagnostic step + provider = get_ai_provider(settings.get_model_for_action("open_chat")) + raw_response, input_tokens, output_tokens = await provider.generate_json( + system_prompt=system_prompt, + messages=messages, + max_tokens=2048, + ) + + # Parse with retry on failure + try: + parsed = _parse_structured_output(raw_response) + except ValueError: + # Retry once with nudge + retry_messages = messages + [ + {"role": "assistant", "content": raw_response}, + {"role": "user", "content": "Please respond with ONLY valid JSON matching the required schema. No markdown or prose."}, + ] + raw_response, retry_in, retry_out = await provider.generate_json( + system_prompt=system_prompt, + messages=retry_messages, + max_tokens=2048, + ) + input_tokens += retry_in + output_tokens += retry_out + parsed = _parse_structured_output(raw_response) + + confidence = parsed.get("confidence", 0.0) + confidence_tier = _confidence_to_tier(confidence) + + # Initial confidence from match + classification + if top_match and top_match.get("score", 0) > 0.8: + confidence_tier = "guided" + confidence = max(confidence, 0.8) + + # 6. Create session + session = AISession( + id=uuid.uuid4(), + user_id=user_id, + account_id=account_id, + team_id=team_id, + intake_type=request.intake_type, + intake_content=request.intake_content, + problem_summary=classification.get("problem_summary"), + problem_domain=classification.get("problem_domain"), + status="active", + confidence_tier=confidence_tier, + confidence_score=confidence, + matched_flow_id=matched_flow_id, + match_score=match_score, + psa_ticket_id=request.psa_ticket_id, + psa_connection_id=request.psa_connection_id, + ticket_data=ticket_data, + total_input_tokens=input_tokens, + total_output_tokens=output_tokens, + step_count=1, + system_prompt_snapshot=system_prompt, + conversation_messages=[ + {"role": "user", "content": user_message}, + {"role": "assistant", "content": raw_response}, + ], + ) + db.add(session) + + # 7a. Update matched flow usage tracking + if matched_flow_id: + try: + flow_result = await db.get(Tree, matched_flow_id) + if flow_result: + flow_result.usage_count = (flow_result.usage_count or 0) + 1 + flow_result.last_matched_at = datetime.now(timezone.utc) + except Exception as e: + logger.warning("Failed to update flow usage stats for flow %s: %s", matched_flow_id, e) + + # 7. Create first step + step = _create_step_from_parsed( + session_id=session.id, + step_order=0, + parsed=parsed, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + db.add(step) + + await db.flush() + + # Generate session embedding for similar-session matching (fire-and-forget) + try: + from app.services.session_embedding_service import generate_session_embedding + await generate_session_embedding(session.id, db) + except Exception: + logger.warning("Failed to generate session embedding on create", exc_info=True) + + return AISessionCreateResponse( + session_id=session.id, + status=session.status, + confidence_tier=session.confidence_tier, + problem_summary=session.problem_summary, + problem_domain=session.problem_domain, + matched_flow_id=matched_flow_id, + matched_flow_name=matched_flow_name, + match_score=match_score, + first_step=_build_step_response(step, session), + psa_context_status=psa_context_status, + ) + + +async def process_response( + session_id: UUID, + request: StepResponseRequest, + user_id: UUID, + db: AsyncSession, +) -> StepResponseResponse: + """Process an engineer's response and generate the next FlowPilot step.""" + + session = await _load_session(session_id, user_id, db) + + if session.status != "active": + raise ValueError(f"Session is {session.status}, not active") + + if session.step_count >= MAX_STEPS_PER_SESSION: + raise ValueError("Maximum steps reached for this session") + + # Update the current (latest) step with engineer's response + latest_step = session.steps[-1] if session.steps else None + if latest_step and latest_step.responded_at is None: + latest_step.selected_option = request.selected_option + latest_step.free_text_input = request.free_text_input + latest_step.was_free_text = bool(request.free_text_input and not request.selected_option) + latest_step.was_skipped = request.was_skipped + latest_step.action_result = request.action_result + latest_step.responded_at = datetime.now(timezone.utc) + + # Build the conversation message for the engineer's response + response_text = _format_engineer_response(request) + session.conversation_messages = session.conversation_messages + [ + {"role": "user", "content": response_text} + ] + + # Call LLM with full conversation + provider = get_ai_provider(settings.get_model_for_action("open_chat")) + raw_response, input_tokens, output_tokens = await provider.generate_json( + system_prompt=session.system_prompt_snapshot or "", + messages=session.conversation_messages, + max_tokens=2048, + ) + + try: + parsed = _parse_structured_output(raw_response) + except ValueError: + retry_messages = session.conversation_messages + [ + {"role": "assistant", "content": raw_response}, + {"role": "user", "content": "Please respond with ONLY valid JSON matching the required schema."}, + ] + raw_response, retry_in, retry_out = await provider.generate_json( + system_prompt=session.system_prompt_snapshot or "", + messages=retry_messages, + max_tokens=2048, + ) + input_tokens += retry_in + output_tokens += retry_out + parsed = _parse_structured_output(raw_response) + + # Append assistant response to conversation + session.conversation_messages = session.conversation_messages + [ + {"role": "assistant", "content": raw_response} + ] + + # Update session confidence + confidence = parsed.get("confidence", session.confidence_score) + session.confidence_score = confidence + session.confidence_tier = _confidence_to_tier(confidence) + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + session.step_count += 1 + + # Create new step + step = _create_step_from_parsed( + session_id=session.id, + step_order=session.step_count - 1, + parsed=parsed, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + db.add(step) + + await db.flush() + + # Check if resolution was suggested + resolution_suggested = parsed["type"] == "resolution_suggestion" + resolution_summary = parsed.get("resolution_summary") if resolution_suggested else None + + return StepResponseResponse( + session_id=session.id, + status=session.status, + confidence_tier=session.confidence_tier, + confidence_score=session.confidence_score, + next_step=_build_step_response(step, session), + resolution_suggested=resolution_suggested, + resolution_summary=resolution_summary, + ) + + +async def resolve_session( + session_id: UUID, + request: ResolveSessionRequest, + user_id: UUID, + db: AsyncSession, +) -> SessionCloseResponse: + """Close a session as resolved and generate documentation.""" + session = await _load_session(session_id, user_id, db) + + if session.status not in ("active", "paused"): + raise ValueError(f"Cannot resolve session in status: {session.status}") + + session.status = "resolved" + session.resolved_at = datetime.now(timezone.utc) + session.resolution_summary = request.resolution_summary + session.resolution_action = request.resolution_action + + if request.session_rating is not None: + session.session_rating = request.session_rating + if request.session_feedback is not None: + session.session_feedback = request.session_feedback + + documentation = _generate_documentation(session) + + # Queue for Knowledge Flywheel analysis + session.analysis_status = "pending" + + # Recalculate success_rate for the matched flow + if session.matched_flow_id: + try: + flow = await db.get(Tree, session.matched_flow_id) + if flow: + total_result = await db.execute( + select(func.count(AISession.id)) + .where(AISession.matched_flow_id == flow.id) + ) + resolved_result = await db.execute( + select(func.count(AISession.id)) + .where( + AISession.matched_flow_id == flow.id, + AISession.status == "resolved", + ) + ) + total = total_result.scalar() or 0 + resolved_count = resolved_result.scalar() or 0 + flow.success_rate = round(resolved_count / total, 3) if total else None + except Exception as e: + logger.warning("Failed to recalculate success_rate for flow %s: %s", session.matched_flow_id, e) + + await db.flush() + + # Update session embedding with resolution data for similar-session matching + try: + from app.services.session_embedding_service import generate_session_embedding + await generate_session_embedding(session.id, db) + except Exception: + logger.warning("Failed to update session embedding on resolve", exc_info=True) + + # Push documentation to PSA if ticket is linked + psa_result = await _push_to_psa(session, user_id, db) + + return SessionCloseResponse( + session_id=session.id, + status=session.status, + documentation=documentation, + **psa_result, + ) + + +async def escalate_session( + session_id: UUID, + request: EscalateSessionRequest, + user_id: UUID, + db: AsyncSession, +) -> SessionCloseResponse: + """Escalate a session — sets status to requesting_escalation for pickup.""" + session = await _load_session(session_id, user_id, db) + + if session.status not in ("active", "paused"): + raise ValueError(f"Cannot escalate session in status: {session.status}") + + # Block self-escalation + if request.escalated_to_id and request.escalated_to_id == user_id: + raise ValueError("Cannot escalate a session to yourself. Use pause instead.") + + session.status = "requesting_escalation" + # Don't set resolved_at — session isn't done yet + session.escalation_reason = request.escalation_reason + session.escalated_to_id = request.escalated_to_id + + # Build enhanced escalation package + session.escalation_package = await _build_escalation_package_enhanced(session, user_id) + + documentation = _generate_documentation(session) + + await db.flush() + + # Notify about escalation + await notify("session.escalated", session.account_id, { + "session_id": str(session_id), + "engineer_name": session.user.name if session.user else "Unknown", + "escalation_reason": request.escalation_reason, + "problem_summary": session.problem_summary or "N/A", + "link": f"/pilot/{session_id}", + }, db, target_user_ids=[request.escalated_to_id] if request.escalated_to_id else None) + + # Push documentation to PSA if ticket is linked + psa_result = await _push_to_psa(session, user_id, db) + + return SessionCloseResponse( + session_id=session.id, + status=session.status, + documentation=documentation, + **psa_result, + ) + + +async def pickup_session( + session_id: UUID, + resume_mode: str, + additional_context: Optional[str], + user_id: UUID, + team_id: Optional[UUID], + db: AsyncSession, +) -> StepResponseResponse: + """Pick up an escalated session as a new engineer. + + Generates a briefing step summarizing prior work, then either continues + the conversation or starts fresh with the new engineer's context. + """ + session = await _load_session( + session_id, user_id, db, + allow_team_access=True, team_id=team_id, + ) + + if session.status != "requesting_escalation": + raise ValueError(f"Session is {session.status}, not requesting_escalation") + + # Can't pick up your own session + if session.user_id == user_id: + raise ValueError("Cannot pick up your own escalated session") + + # Record the pickup in the escalation package + pkg = session.escalation_package or {} + pkg["picked_up_by"] = str(user_id) + pkg["picked_up_at"] = datetime.now(timezone.utc).isoformat() + session.escalation_package = pkg + + # Reactivate the session + session.status = "active" + + # Build a briefing message for the new engineer + original_user_name = "the previous engineer" + if session.user and session.user.name: + original_user_name = session.user.name + + briefing_parts = [ + f"## Escalation Briefing", + f"**Escalated by:** {original_user_name}", + f"**Reason:** {session.escalation_reason or 'Not specified'}", + "", + f"**Problem:** {session.problem_summary or 'Unknown'}", + ] + + steps_tried = pkg.get("steps_tried", []) + if steps_tried: + briefing_parts.append("") + briefing_parts.append("**Steps already taken:**") + for i, step in enumerate(steps_tried, 1): + desc = step.get("description", "") + resp = step.get("response", "") + briefing_parts.append(f"{i}. {desc}") + if resp: + briefing_parts.append(f" → {resp}") + + if hypotheses := pkg.get("remaining_hypotheses"): + briefing_parts.append("") + briefing_parts.append("**Remaining hypotheses:**") + if isinstance(hypotheses, list): + for h in hypotheses: + briefing_parts.append(f"- {h}") + else: + briefing_parts.append(str(hypotheses)) + + if suggestions := pkg.get("suggested_next_steps"): + briefing_parts.append("") + briefing_parts.append("**Suggested next steps:**") + if isinstance(suggestions, list): + for s in suggestions: + briefing_parts.append(f"- {s}") + else: + briefing_parts.append(str(suggestions)) + + briefing_text = "\n".join(briefing_parts) + + # Create a briefing step (special intake_analysis type) + briefing_step = AISessionStep( + id=uuid.uuid4(), + session_id=session.id, + step_order=session.step_count, + step_type="action", + content={ + "text": briefing_text, + "type": "briefing", + "allow_free_text": False, + "allow_skip": False, + }, + context_message="Escalation briefing — here's what was tried before you.", + confidence_at_step=session.confidence_score, + ai_reasoning="Escalation handoff briefing for receiving engineer", + input_tokens=0, + output_tokens=0, + ) + db.add(briefing_step) + session.step_count += 1 + + # Now generate the next step based on resume_mode + if resume_mode == "fresh" and additional_context: + # Engineer B provides their own input + user_message = f"[Picking up escalated session] {additional_context}" + else: + # Continue where A left off + user_message = ( + "[Picking up escalated session] I've reviewed the briefing above. " + "Please continue the diagnosis based on everything tried so far." + ) + + # Append to conversation + session.conversation_messages = session.conversation_messages + [ + {"role": "user", "content": user_message} + ] + + # Call LLM for next step + provider = get_ai_provider(settings.get_model_for_action("open_chat")) + raw_response, input_tokens, output_tokens = await provider.generate_json( + system_prompt=session.system_prompt_snapshot or "", + messages=session.conversation_messages, + max_tokens=2048, + ) + + try: + parsed = _parse_structured_output(raw_response) + except ValueError: + retry_messages = session.conversation_messages + [ + {"role": "assistant", "content": raw_response}, + {"role": "user", "content": "Please respond with ONLY valid JSON matching the required schema."}, + ] + raw_response, retry_in, retry_out = await provider.generate_json( + system_prompt=session.system_prompt_snapshot or "", + messages=retry_messages, + max_tokens=2048, + ) + input_tokens += retry_in + output_tokens += retry_out + parsed = _parse_structured_output(raw_response) + + session.conversation_messages = session.conversation_messages + [ + {"role": "assistant", "content": raw_response} + ] + + confidence = parsed.get("confidence", session.confidence_score) + session.confidence_score = confidence + session.confidence_tier = _confidence_to_tier(confidence) + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + session.step_count += 1 + + next_step = _create_step_from_parsed( + session_id=session.id, + step_order=session.step_count - 1, + parsed=parsed, + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + db.add(next_step) + + await db.flush() + + return StepResponseResponse( + session_id=session.id, + status=session.status, + confidence_tier=session.confidence_tier, + confidence_score=session.confidence_score, + next_step=_build_step_response(next_step, session), + resolution_suggested=parsed["type"] == "resolution_suggestion", + resolution_summary=parsed.get("resolution_summary") if parsed["type"] == "resolution_suggestion" else None, + ) + + +async def link_ticket( + session_id: UUID, + psa_ticket_id: str, + psa_connection_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> None: + """Link a PSA ticket to an in-progress session and inject context.""" + session = await _load_session(session_id, user_id, db) + + if session.status not in ("active", "paused"): + raise ValueError(f"Cannot link ticket to session in status: {session.status}") + + # Store the ticket link + session.psa_ticket_id = psa_ticket_id + session.psa_connection_id = psa_connection_id + + # Try to fetch ticket context + ticket_context_block, ticket_data, _ = await _process_ticket_intake( + psa_connection_id=psa_connection_id, + psa_ticket_id=psa_ticket_id, + db=db, + ) + + if ticket_data: + session.ticket_data = ticket_data + + # Inject ticket context into the system prompt for subsequent steps + if ticket_context_block and session.system_prompt_snapshot: + ticket_section = f"\n\n## PSA TICKET CONTEXT (linked mid-session)\n{ticket_context_block}\n" + session.system_prompt_snapshot = session.system_prompt_snapshot + ticket_section + + await db.flush() + + +async def pause_session( + session_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> None: + """Pause an active session for the same engineer to resume later.""" + session = await _load_session(session_id, user_id, db) + + if session.status != "active": + raise ValueError(f"Cannot pause session in status: {session.status}") + + session.status = "paused" + await db.flush() + + +async def resume_session( + session_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> None: + """Resume a paused session for the same engineer.""" + session = await _load_session(session_id, user_id, db) + + if session.status != "paused": + raise ValueError(f"Cannot resume session in status: {session.status}") + + session.status = "active" + await db.flush() + + +async def rate_session( + session_id: UUID, + rating: int, + feedback: Optional[str], + user_id: UUID, + db: AsyncSession, +) -> None: + """Submit post-session rating.""" + session = await _load_session(session_id, user_id, db) + session.session_rating = rating + session.session_feedback = feedback + await db.flush() + + +async def get_session_documentation( + session_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> SessionDocumentation: + """Get auto-generated documentation for a session.""" + session = await _load_session(session_id, user_id, db) + return _generate_documentation(session) + + +# ── Internal helpers ── + +async def _load_session( + session_id: UUID, + user_id: UUID, + db: AsyncSession, + allow_team_access: bool = False, + team_id: Optional[UUID] = None, +) -> AISession: + """Load session with steps and user relationships, verifying ownership. + + Args: + allow_team_access: If True, same-team users can access sessions in + 'requesting_escalation' status (for escalation pickup). + team_id: Required when allow_team_access is True. + """ + result = await db.execute( + select(AISession) + .options( + selectinload(AISession.steps), + selectinload(AISession.user), + selectinload(AISession.escalated_to), + ) + .where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + + if not session: + raise ValueError("Session not found") + + # Owner or escalation target always has access + if session.user_id == user_id or session.escalated_to_id == user_id: + return session + + # Engineer who picked up an escalated session has access + pkg = session.escalation_package or {} + if pkg.get("picked_up_by") == str(user_id): + return session + + # Team-based access for escalation pickup + if allow_team_access and team_id and session.team_id == team_id: + if session.status == "requesting_escalation": + return session + + raise PermissionError("Not authorized to access this session") + + +async def _classify_intake(intake_text: str) -> dict[str, Any]: + """Quick LLM call to classify intake content.""" + try: + provider = get_ai_provider(settings.get_model_for_action("quick_action")) + raw, _, _ = await provider.generate_json( + system_prompt=INTAKE_CLASSIFICATION_PROMPT, + messages=[{"role": "user", "content": intake_text}], + max_tokens=512, + ) + return json.loads(raw.strip()) + except Exception as e: + logger.warning("Intake classification failed: %s", e) + return { + "problem_summary": intake_text[:120], + "problem_domain": "other", + "key_symptoms": [], + "urgency": "medium", + } + + +def _extract_intake_text(intake_content: dict[str, Any]) -> str: + """Extract searchable text from intake content.""" + parts = [] + if text := intake_content.get("text"): + parts.append(text) + if log := intake_content.get("log_content"): + parts.append(f"Log output:\n{log}") + if ticket := intake_content.get("ticket_data"): + if isinstance(ticket, dict): + parts.append(f"Ticket: {ticket.get('summary', '')}") + return "\n\n".join(parts) if parts else str(intake_content) + + +def _format_intake_message( + intake_content: dict[str, Any], + classification: dict[str, Any], +) -> str: + """Format intake + classification into the first user message.""" + parts = ["I need help troubleshooting an issue."] + + if text := intake_content.get("text"): + parts.append(f"\n**Problem description:**\n{text}") + + if log := intake_content.get("log_content"): + parts.append(f"\n**Log output:**\n```\n{log}\n```") + + if summary := classification.get("problem_summary"): + parts.append(f"\n**Classified as:** {summary}") + + if domain := classification.get("problem_domain"): + parts.append(f"**Domain:** {domain}") + + symptoms = classification.get("key_symptoms", []) + if symptoms: + parts.append(f"**Key symptoms:** {', '.join(symptoms)}") + + return "\n".join(parts) + + +def _format_engineer_response(request: StepResponseRequest) -> str: + """Format engineer's step response into a conversation message.""" + if request.was_skipped: + return "I can't check this right now / I don't know." + + parts = [] + if request.selected_option: + parts.append(f"Selected: {request.selected_option}") + + if request.free_text_input: + parts.append(request.free_text_input) + + if request.action_result: + result = request.action_result + success = "succeeded" if result.get("success") else "did not work" + parts.append(f"Action {success}.") + if details := result.get("details"): + parts.append(f"Details: {details}") + + return "\n".join(parts) if parts else "No response provided." + + +def _create_step_from_parsed( + session_id: UUID, + step_order: int, + parsed: dict[str, Any], + input_tokens: int, + output_tokens: int, +) -> AISessionStep: + """Create an AISessionStep from parsed LLM output.""" + step_type = parsed["type"] + if step_type == "resolution_suggestion": + step_type = "action" # Store as action in DB, UI distinguishes via content + + # Build content dict (everything the UI needs to render) + content = { + "text": parsed.get("content", ""), + "type": parsed["type"], + } + if parsed["type"] == "action": + content["action_type"] = parsed.get("action_type", "instruction") + content["expected_outcome"] = parsed.get("expected_outcome") + # Script generation fields (populated when FlowPilot suggests a script) + if parsed.get("template_id"): + content["template_id"] = parsed["template_id"] + if parsed.get("pre_filled_params"): + content["pre_filled_params"] = parsed["pre_filled_params"] + if parsed.get("instructions"): + content["instructions"] = parsed["instructions"] + elif parsed["type"] == "resolution_suggestion": + content["resolution_summary"] = parsed.get("resolution_summary") + content["follow_up_recommendations"] = parsed.get("follow_up_recommendations", []) + content["allow_free_text"] = False + content["allow_skip"] = False + + # Extract options for question type + options = None + if parsed["type"] == "question" and "options" in parsed: + options = parsed["options"] + content["allow_free_text"] = parsed.get("allow_free_text", True) + content["allow_skip"] = parsed.get("allow_skip", True) + + return AISessionStep( + id=uuid.uuid4(), + session_id=session_id, + step_order=step_order, + step_type=step_type if parsed["type"] != "resolution_suggestion" else "action", + content=content, + context_message=parsed.get("context_message"), + options_presented=options, + confidence_at_step=parsed.get("confidence", 0.0), + ai_reasoning=parsed.get("reasoning"), + input_tokens=input_tokens, + output_tokens=output_tokens, + ) + + +def _generate_documentation(session: AISession) -> SessionDocumentation: + """Generate structured documentation from a session's steps.""" + diagnostic_steps = [] + + for step in session.steps: + content = step.content or {} + description = content.get("text", "") + + # Determine engineer response + engineer_response = None + if step.was_skipped: + engineer_response = "Skipped" + elif step.selected_option: + # Find the label for the selected option + if step.options_presented: + for opt in step.options_presented: + if opt.get("value") == step.selected_option: + engineer_response = opt.get("label", step.selected_option) + break + else: + engineer_response = step.selected_option + else: + engineer_response = step.selected_option + elif step.free_text_input: + engineer_response = step.free_text_input + + # Determine outcome + outcome = None + 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}" + + diagnostic_steps.append(DocumentationStep( + step_number=step.step_order + 1, + step_type=step.step_type, + description=description, + engineer_response=engineer_response, + outcome=outcome, + )) + + # Calculate duration + duration_display = None + if session.resolved_at and session.created_at: + delta = session.resolved_at - session.created_at + minutes = int(delta.total_seconds() / 60) + if minutes < 60: + duration_display = f"{minutes}m" + else: + hours = minutes // 60 + remaining = minutes % 60 + duration_display = f"{hours}h {remaining}m" + + # Build intake summary + intake = session.intake_content or {} + intake_summary = intake.get("text", "")[:500] + if not intake_summary: + intake_summary = str(intake)[:500] + + return SessionDocumentation( + problem_summary=session.problem_summary or "No summary available", + problem_domain=session.problem_domain, + intake_summary=intake_summary, + diagnostic_steps=diagnostic_steps, + resolution_summary=session.resolution_summary, + escalation_reason=session.escalation_reason, + total_steps=session.step_count, + duration_display=duration_display, + generated_at=datetime.now(timezone.utc), + ) + + +async def _push_to_psa( + session: AISession, + user_id: UUID, + db: AsyncSession, +) -> dict[str, Any]: + """Push documentation to PSA if session has a linked ticket. + + Returns dict with psa_push_status, psa_push_error, member_mapping_warning. + """ + 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} + + try: + from app.services.psa_documentation_service import push_documentation + return await push_documentation(session, user_id, db) + except Exception as e: + logger.warning("PSA documentation push failed for session %s: %s", session.id, e) + return { + "psa_push_status": "failed", + "psa_push_error": str(e)[:200], + "member_mapping_warning": None, + } + + +async def _process_ticket_intake( + psa_connection_id: UUID, + psa_ticket_id: str, + db: AsyncSession, +) -> tuple[Optional[str], Optional[dict[str, Any]], str]: + """Fetch ticket context from PSA and format for AI prompt. + + Returns: + (ticket_context_block, ticket_data_dict, psa_context_status) + - ticket_context_block: formatted text for system prompt, or None on failure + - ticket_data_dict: serialized TicketContext for storage, or None on failure + - psa_context_status: "loaded" or "unavailable" + """ + try: + from app.services.psa.registry import get_provider_for_connection + from app.services.psa.ticket_context import format_ticket_context_for_prompt + + 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) + ticket_data = ticket_context.model_dump(mode="json") + return ticket_prompt_block, ticket_data, "loaded" + except Exception as e: + logger.warning( + "Failed to fetch ticket context for ticket %s (connection %s): %s", + psa_ticket_id, psa_connection_id, e, + ) + return None, None, "unavailable" + + +async def _build_script_context( + team_id: Optional[UUID], + db: AsyncSession, +) -> Optional[str]: + """Build script template context for the system prompt. + + Includes available script templates so FlowPilot can suggest + script_generation actions with pre-filled parameters. + """ + try: + from app.models.script_template import ScriptTemplate + + result = await db.execute( + select(ScriptTemplate) + .where( + ScriptTemplate.is_active.is_(True), + or_( + ScriptTemplate.team_id.is_(None), + ScriptTemplate.team_id == team_id, + ), + ) + .order_by(ScriptTemplate.usage_count.desc()) + .limit(20) + ) + templates = result.scalars().all() + + if not templates: + return None + + lines = ["## AVAILABLE SCRIPTS"] + lines.append("When the engineer needs to run a script, suggest an action with action_type='script_generation'.") + lines.append("Include template_id and pre_filled_params based on the diagnostic context.\n") + for t in templates: + params = t.parameters_schema.get("parameters", []) + param_keys = ", ".join(p.get("key", "") for p in params if p.get("key")) + lines.append(f"- {t.name} (ID: {t.id}): {t.description or 'No description'}") + if param_keys: + lines.append(f" Parameters: {param_keys}") + + return "\n".join(lines) + except Exception as e: + logger.warning("Failed to build script context: %s", e) + return None + + +async def _build_escalation_package_enhanced( + session: AISession, + user_id: UUID, +) -> dict[str, Any]: + """Build enhanced context package with LLM-generated hypotheses.""" + steps_tried = [] + for step in session.steps: + content = step.content or {} + entry = { + "step_type": step.step_type, + "description": content.get("text", ""), + } + if step.selected_option: + entry["response"] = step.selected_option + elif step.free_text_input: + entry["response"] = step.free_text_input + elif step.was_skipped: + entry["response"] = "Skipped" + if step.action_result: + entry["action_result"] = step.action_result + steps_tried.append(entry) + + package = { + "original_user_id": str(user_id), + "problem_summary": session.problem_summary, + "problem_domain": session.problem_domain, + "intake_content": session.intake_content, + "confidence_at_escalation": session.confidence_score, + "steps_tried": steps_tried, + "escalation_reason": session.escalation_reason, + } + + # LLM call for remaining hypotheses and suggested next steps (fast model) + try: + conversation_summary = "\n".join( + f"- {s.get('description', '')} → {s.get('response', 'no response')}" + for s in steps_tried + ) + prompt = ( + "Based on this diagnostic conversation for an IT troubleshooting session:\n\n" + f"Problem: {session.problem_summary}\n" + f"Domain: {session.problem_domain}\n\n" + f"Steps taken:\n{conversation_summary}\n\n" + f"Escalation reason: {session.escalation_reason}\n\n" + "Respond with ONLY a JSON object:\n" + '{"remaining_hypotheses": ["hypothesis1", "hypothesis2"], ' + '"suggested_next_steps": ["step1", "step2"], ' + '"steps_ruled_out": ["ruled_out1"]}' + ) + provider = get_ai_provider(settings.get_model_for_action("quick_action")) + raw, _, _ = await provider.generate_json( + system_prompt="You are an expert IT diagnostic assistant. Analyze the escalation context and provide concise insights.", + messages=[{"role": "user", "content": prompt}], + max_tokens=1024, + ) + insights = json.loads(raw.strip().strip("`").lstrip("json\n")) + package["remaining_hypotheses"] = insights.get("remaining_hypotheses", []) + package["suggested_next_steps"] = insights.get("suggested_next_steps", []) + package["steps_ruled_out"] = insights.get("steps_ruled_out", []) + except Exception as e: + logger.warning("Failed to generate escalation insights: %s", e) + # Fall back gracefully — don't block the escalation + + return package diff --git a/backend/app/services/knowledge_flywheel.py b/backend/app/services/knowledge_flywheel.py new file mode 100644 index 00000000..36f23cad --- /dev/null +++ b/backend/app/services/knowledge_flywheel.py @@ -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: ", + "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, + ) + + diff --git a/backend/app/services/knowledge_flywheel_scheduler.py b/backend/app/services/knowledge_flywheel_scheduler.py new file mode 100644 index 00000000..c1366b7d --- /dev/null +++ b/backend/app/services/knowledge_flywheel_scheduler.py @@ -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) diff --git a/backend/app/services/knowledge_gap_service.py b/backend/app/services/knowledge_gap_service.py new file mode 100644 index 00000000..f476efa9 --- /dev/null +++ b/backend/app/services/knowledge_gap_service.py @@ -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 diff --git a/backend/app/services/llm_utils.py b/backend/app/services/llm_utils.py new file mode 100644 index 00000000..15f8ac71 --- /dev/null +++ b/backend/app/services/llm_utils.py @@ -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 diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 00000000..23926b0f --- /dev/null +++ b/backend/app/services/notification_service.py @@ -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 diff --git a/backend/app/services/psa/autotask/__init__.py b/backend/app/services/psa/autotask/__init__.py new file mode 100644 index 00000000..19019d55 --- /dev/null +++ b/backend/app/services/psa/autotask/__init__.py @@ -0,0 +1,3 @@ +from app.services.psa.autotask.provider import AutotaskProvider + +__all__ = ["AutotaskProvider"] diff --git a/backend/app/services/psa/autotask/provider.py b/backend/app/services/psa/autotask/provider.py new file mode 100644 index 00000000..ed7ca8d8 --- /dev/null +++ b/backend/app/services/psa/autotask/provider.py @@ -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") diff --git a/backend/app/services/psa/base.py b/backend/app/services/psa/base.py index e2230aa0..f2522e43 100644 --- a/backend/app/services/psa/base.py +++ b/backend/app/services/psa/base.py @@ -11,6 +11,7 @@ from .types import ( PSACompany, PSAMember, PSAConfiguration, + PSATimeEntry, ) @@ -66,3 +67,14 @@ class PSAProvider(ABC): @abstractmethod 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: + ... diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index a4aca59b..34b2ecd3 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -15,6 +15,7 @@ from app.services.psa.types import ( PSACompany, PSAMember, PSAConfiguration, + PSATimeEntry, ) from .client import ConnectWiseClient @@ -514,6 +515,37 @@ class ConnectWiseProvider(PSAProvider): psa_cache.set(cache_key, ctx, ttl_seconds=300) 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 ─────────────────────────────────────────────── @staticmethod diff --git a/backend/app/services/psa/halopsa/__init__.py b/backend/app/services/psa/halopsa/__init__.py new file mode 100644 index 00000000..a69ce468 --- /dev/null +++ b/backend/app/services/psa/halopsa/__init__.py @@ -0,0 +1,3 @@ +from app.services.psa.halopsa.provider import HaloPSAProvider + +__all__ = ["HaloPSAProvider"] diff --git a/backend/app/services/psa/halopsa/provider.py b/backend/app/services/psa/halopsa/provider.py new file mode 100644 index 00000000..92e4b757 --- /dev/null +++ b/backend/app/services/psa/halopsa/provider.py @@ -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") diff --git a/backend/app/services/psa/registry.py b/backend/app/services/psa/registry.py index ff84c3cc..ab09222b 100644 --- a/backend/app/services/psa/registry.py +++ b/backend/app/services/psa/registry.py @@ -31,6 +31,32 @@ async def get_provider_for_account( 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": from app.services.psa.connectwise.client import ConnectWiseClient from app.services.psa.connectwise.provider import ConnectWiseProvider diff --git a/backend/app/services/psa/types.py b/backend/app/services/psa/types.py index 9515ab6c..49338079 100644 --- a/backend/app/services/psa/types.py +++ b/backend/app/services/psa/types.py @@ -57,6 +57,16 @@ class PSAConfiguration(BaseModel): 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: INTERNAL_ANALYSIS = "internal_analysis" RESOLUTION = "resolution" diff --git a/backend/app/services/psa_documentation_service.py b/backend/app/services/psa_documentation_service.py new file mode 100644 index 00000000..6a40bbf5 --- /dev/null +++ b/backend/app/services/psa_documentation_service.py @@ -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 diff --git a/backend/app/services/psa_retry_scheduler.py b/backend/app/services/psa_retry_scheduler.py new file mode 100644 index 00000000..f3403eb2 --- /dev/null +++ b/backend/app/services/psa_retry_scheduler.py @@ -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() diff --git a/backend/app/services/session_embedding_service.py b/backend/app/services/session_embedding_service.py new file mode 100644 index 00000000..65fe27da --- /dev/null +++ b/backend/app/services/session_embedding_service.py @@ -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 + ] diff --git a/backend/app/services/session_to_flow_service.py b/backend/app/services/session_to_flow_service.py index 4911e9dc..626d3711 100644 --- a/backend/app/services/session_to_flow_service.py +++ b/backend/app/services/session_to_flow_service.py @@ -5,7 +5,6 @@ flow with fallback branches, powered by AI. """ import json import logging -import re import uuid from typing import Any, Optional 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.config import settings 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.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: @@ -222,11 +215,7 @@ async def generate_flow_from_session( ) # Strip markdown fences and parse JSON - raw_text = _strip_markdown_fences(raw_text) - try: - generated = json.loads(raw_text) - except json.JSONDecodeError as e: - raise ValueError(f"AI returned invalid JSON: {e}") from e + generated = parse_llm_json(raw_text) # Validate the generated steps val_errors = validate_generated_procedural_steps(generated) diff --git a/backend/app/services/sso_service.py b/backend/app/services/sso_service.py new file mode 100644 index 00000000..33226a9d --- /dev/null +++ b/backend/app/services/sso_service.py @@ -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") diff --git a/backend/app/services/storage_service.py b/backend/app/services/storage_service.py new file mode 100644 index 00000000..d6e9399c --- /dev/null +++ b/backend/app/services/storage_service.py @@ -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}") diff --git a/backend/app/templates/export_pdf.html b/backend/app/templates/export_pdf.html index 8a7b7c0a..7900a7dd 100644 --- a/backend/app/templates/export_pdf.html +++ b/backend/app/templates/export_pdf.html @@ -3,7 +3,6 @@