diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..bab9e749 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,32 @@ +# Copilot Instructions — ResolutionFlow + +## Design Context + +### Users +MSP engineers — IT professionals at Managed Service Providers who troubleshoot infrastructure and support issues for multiple client companies. They work under ticket pressure, need to resolve issues fast, and produce clean documentation automatically. + +### Brand Personality +**Three words:** Professional, Modern, SaaS +**Voice:** Direct, competent, no fluff. Built by MSP engineers, for MSP engineers. +**Emotional goals:** Confidence, competence, clarity, focus. + +### Aesthetic Direction +- Flat, high-contrast dark theme (Sentry/PostHog-inspired). Premium and clean. +- **References:** Notion (clarity), Stripe (polish), Figma (functional density) +- **Anti-references:** Microsoft Teams (clutter), Kaseya VSA 9 (dated patterns) +- Accent: ember orange (#f97316), max 5% of UI. No glassmorphism, no gradient surfaces, no ambient effects. +- See `DESIGN-SYSTEM.md` for full token and component specs. + +### Accessibility +- WCAG 2.2 AA baseline +- Enhanced focus appearance on all interactive elements +- 7:1 contrast ratio for data visualization colors +- `prefers-reduced-motion` fully supported +- Never rely on color alone for status — pair with icons or text + +### Design Principles +1. **Clarity over decoration.** Every pixel should communicate. No ornamental effects. +2. **Density without clutter.** Use typography hierarchy and spacing to create structure, not chrome. +3. **Confidence through consistency.** Same patterns, same tokens, same behavior everywhere. +4. **Speed is a feature.** Minimize clicks. Copilot-first — primary interaction is typing. +5. **Accessible by default.** WCAG 2.2, enhanced focus, high-contrast data viz, motion sensitivity. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf17e58a..5415e8df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: services: postgres: - image: postgres:16 + image: pgvector/pgvector:pg16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -100,7 +100,7 @@ jobs: services: postgres: - image: postgres:16 + image: pgvector/pgvector:pg16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 00000000..5b5813d6 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,68 @@ +# Design Context — ResolutionFlow + +> Persistent design guidance for all AI sessions. Source of truth for design intent and principles. +> For component specs, tokens, and implementation details, see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). + +## Users + +**MSP engineers** — IT professionals at Managed Service Providers who troubleshoot infrastructure and support issues for multiple client companies. They work under ticket pressure, juggling PSA tools (ConnectWise, Autotask, HaloPSA) and need to resolve issues fast while producing clean documentation. + +**Context of use:** Mid-ticket, often stressed, switching between tools. They need the interface to get out of their way and help them think clearly. Documentation is a pain point — it should feel automatic, not like extra work. + +**Job to be done:** Describe an issue, get guided through resolution, and walk away with professional ticket notes — without manual writeup. + +## Brand Personality + +**Three words:** Professional, Modern, SaaS + +**Voice:** Direct, competent, no fluff. Built by MSP engineers, for MSP engineers. The product speaks like a senior colleague — helpful without being patronizing, technical without being dense. + +**Emotional goals:** Confidence, competence, clarity, focus. The interface should make engineers feel like they have a reliable system backing them up. Every interaction should reinforce trust and reduce cognitive load. + +## Aesthetic Direction + +**Visual tone:** Flat, high-contrast dark theme. Premium and clean — Sentry/PostHog DNA. Minimal decoration, maximum signal. Information density without clutter. + +**References:** +- **Notion** — clarity of layout, whitespace discipline, typography hierarchy +- **Stripe** — polish, professional confidence, attention to micro-detail +- **Figma** — functional density done right, tool-like precision, dark mode execution + +**Anti-references:** +- **Microsoft Teams** — cluttered, inconsistent spacing, overwhelming chrome, unclear hierarchy +- **Kaseya VSA 9** — dated UI patterns, poor information density, legacy enterprise feel + +**Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented. + +**Accent:** Ember orange (#f97316) — conveys urgency fitting a troubleshooting context. Used sparingly (max 5% of UI). Warning uses yellow (#eab308), not amber, to stay distinct. + +**Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow. + +## Accessibility + +**Target:** WCAG 2.2 AA as baseline, with two enhanced commitments: +- **Enhanced focus appearance** — all interactive elements must have visible, high-contrast focus indicators (not just inputs). Keyboard navigation must be obvious and consistent. +- **7:1 contrast ratio for data visualization** — chart colors, graph elements, and any data-bearing color must meet AAA contrast against their background. Standard text follows AA (4.5:1 body, 3:1 large). + +**Already implemented:** +- `prefers-reduced-motion` fully handled (animations collapse to 0.01ms) +- Mobile responsive (app shell collapses below 768px) +- Bottom-sheet modals on mobile +- Styled scrollbars (6px, subtle) + +**Considerations for future work:** +- Color blindness: avoid relying on red/green distinction alone for status — always pair with icons or text labels +- Screen reader: ensure all interactive elements have accessible names +- Keyboard: all flows must be completable without a mouse + +## Design Principles + +1. **Clarity over decoration.** Every pixel should communicate. If an element doesn't help the user understand or act, remove it. No ornamental gradients, glows, or effects. + +2. **Density without clutter.** MSP engineers work with lots of data. Show what matters, hide what doesn't. Use typography hierarchy and spacing — not chrome — to create structure. + +3. **Confidence through consistency.** Same patterns, same tokens, same behavior everywhere. Predictability builds trust. Reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) for every component decision. + +4. **Speed is a feature.** The interface should feel instant. Minimize clicks to action. Auto-generate what can be auto-generated. The copilot-first UX means the primary interaction is typing, not navigating. + +5. **Accessible by default.** WCAG 2.2 compliance isn't a checklist item — it's a design constraint. Enhanced focus, high-contrast data viz, and motion sensitivity are built in, not bolted on. diff --git a/CLAUDE.md b/CLAUDE.md index adc328cb..965f8f29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md - Patherly / ResolutionFlow Project Context -> **Last Updated:** March 23, 2026 +> **Last Updated:** March 27, 2026 --- @@ -23,25 +23,13 @@ - **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned. - **Accent color:** Ember orange (#f97316 / #ea580c). Used sparingly — ≤5% of the UI. Warning is yellow (#eab308), not amber, to stay distinct from accent. - **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts -- **Logo:** 30px gradient square (cyan) + "ResolutionFlow" in Bricolage Grotesque 700 +- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700 - **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) - **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon) - **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. Maintenance flows are hidden from UI for pilot (backend still supports them). `tree_type` column values unchanged in DB. - **Reference mockups:** `docs/mockups/` (HTML files, open in browser) -**Component styling rules:** - -- Primary buttons: solid `accent` background (#f97316), white text, 5px radius -- Ghost buttons: transparent with 1px `border-default`, hover `bg-elevated` -- Cards: `bg-card` with 1px `border-default`, 8px radius. NO shadows, NO blur, NO gradients. -- Badges: pill-shaped (20px radius), semantic dim background + matching text color -- Active nav: `accent-dim` background + `accent-text` color + 3px left accent bar -- Stat cards: 3px colored left border (accent/success/warning by position) -- Code blocks: `bg-code` with JetBrains Mono, material-inspired syntax highlighting -- Status colors: green/`#34d399` (success), yellow/`#eab308` (warning), red/`#f87171` (danger) — ONLY for semantic meaning -- Section labels: 10px, 600 weight, uppercase, `text-muted`, 1.2px letter-spacing - -When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). Use flat dark surfaces, 1px borders, no decorative effects. All colors via CSS variables. Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. +**Component styling:** See Design System section below and [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). All colors via CSS variables. Use "Flows" not "Trees" in user-facing text; use "Projects" not "Procedures" for procedural flows. ## Implementation Principles @@ -54,9 +42,9 @@ When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) ## Current State - **Phase:** Go-to-Market Validation (Pre-PMF) -- **Backend:** Complete (35+ API endpoints, 100+ integration tests) +- **Backend:** Complete (55+ API endpoints, 100+ integration tests) - **Frontend:** Core features complete, Tree Editor functional -- **Database:** PostgreSQL with Docker, 75 migrations +- **Database:** PostgreSQL with Docker, 98 migrations - **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md) ### What's In Progress @@ -65,20 +53,6 @@ When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) - Solutions Library spec written (`docs/plans/2026-03-23-solutions-library-design.md`), implementation post-pilot - Remaining open issues: #66 Templates + Import/Export, #60 Recurring Issue Detection, #58 Step Feedback Flag -### Recently Completed - -- Copilot-first dashboard redesign: ChatGPT-style input, suggestion chips, simplified sidebar -- Charcoal color palette: sidebar-darkest approach (`#10121a` sidebar, `#1a1c23` page, `#22252e` cards) -- Unified Command Palette: merged QuickLaunch into omnibar, removed lightning bolt button -- "Solutions Library" rename from "Step Library" site-wide -- Maintenance flows hidden from UI for pilot -- Landing page copy rewrite: copilot-first messaging ("Resolve tickets faster. Notes write themselves.") -- Spring bounce hover animation on dashboard cards -- Amber "New Session" button in sidebar -- Landing page design audit: hamburger menu, Privacy/Terms pages, branding alignment -- Root directory cleanup: archived 9 completed docs, tracked marketing assets -- GitHub issues triage: closed 10 stale issues (6 completed, 4 deferred) - --- ## Tech Stack @@ -95,7 +69,7 @@ When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) ### Frontend - **Framework:** React 19 + Vite + TypeScript -- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with cyan accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)) +- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with ember orange accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)) - **State:** Zustand (with immer + zundo for undo/redo) - **Routing:** React Router v7 - **API Client:** Axios with token refresh interceptor @@ -110,7 +84,7 @@ patherly/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI entry point -│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, psa_connections) +│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations) │ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD │ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics │ │ ├── api/deps.py # Auth dependencies (includes require_team_admin) @@ -118,7 +92,7 @@ patherly/ │ │ ├── core/ # config, database, permissions, security, audit, rate_limit │ │ ├── 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/, autotask/, halopsa/, 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 @@ -131,7 +105,7 @@ patherly/ │ │ ├── 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) +│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore) │ │ └── types/ # TypeScript interfaces │ └── (Tailwind v4: CSS-only config in src/index.css) ├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026) @@ -202,7 +176,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 `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch -- PSA endpoints in `api/endpoints/psa_connections.py` — connection CRUD, ticket ops, member mapping +- PSA endpoints in `api/endpoints/integrations.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 - Design for the Autotask integration following the same service layer pattern (future PSA) @@ -320,7 +294,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container name is `resolutionflow_postgres`, database is `resolutionflow` (not `patherly`), port mapped to `5433` (not `5432`). The `POSTGRES_PORT` env var controls this. Playwright config defaults must match: `postgresql+asyncpg://postgres:postgres@127.0.0.1:5433/resolutionflow`. -**66. Dev environment runs on devserver01 (192.168.0.9), not localhost:** Code-server runs in Docker on a LAN server. Frontend/backend are accessed via `192.168.0.9`, not `localhost`. CORS must include `http://192.168.0.9:5173` in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL=http://192.168.0.9:8000`. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues. +**66. Dev environment runs on Hostinger VPS (46.202.92.250), not localhost:** Code-server runs in Docker on a VPS (previously devserver01/192.168.0.9). Frontend/backend are accessed via `46.202.92.250`, not `localhost`. CORS must include the VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL` to the VPS backend URL. 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. @@ -366,11 +340,6 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **88. Charcoal palette — sidebar-darkest approach:** Sidebar `#10121a`, page `#1a1c23`, cards `#22252e`, borders `#2e3240`. This gives more contrast range than true-dark (`#0c0d10`). All colors via CSS variables in `index.css` `@theme` block. Accent is ember orange (#f97316), not cyan. -**89. QuickLaunch merged into CommandPalette:** There is no separate QuickLaunch/lightning bolt. The unified Cmd+K omnibar handles search, navigation, quick actions, and FlowPilot. `QuickLaunch.tsx` was deleted. - -**90. Copilot-first UX direction:** The FlowPilot AI chat copilot is the primary experience. Dashboard centers on the chat input. Guided flows (decision trees) are accessible but secondary — in sidebar under "Flows". Maintenance flows are hidden from UI for pilot. - -**91. "New Session" button is amber-400:** Sidebar uses `bg-amber-400/15 text-amber-400` for the New Session button, not cyan. This makes it visually distinct from the cyan accent used elsewhere. **92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing. @@ -386,6 +355,16 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **98. `lazyWithRetry` for stale chunk errors:** All lazy-loaded routes use `lazyWithRetry` from `@/lib/lazyWithRetry.ts` instead of `React.lazy`. Auto-reloads the page on chunk load failures (stale deploys). Uses sessionStorage debounce (10s) to prevent loops. When adding new lazy routes, use `lazyWithRetry`, not `lazy`. +**99. Tailwind v4 `text-secondary` renders invisible on dark backgrounds:** `text-secondary` maps to `--color-secondary: #2e3140` (a dark surface color), NOT `--color-text-secondary`. For readable secondary text, use `text-muted-foreground` (`#848b9b`). Also avoid `text-muted` (`#4f5666`) for body text — it's for labels only. This applies to ALL new components. + +**100. Hover pop-out card pattern:** For cards that expand on hover "in front of everything": use `pointer-events-none` on the scrim (`fixed inset-0 z-40 bg-black/30`), absolute-position the expanded card at `z-50` with its own `onClick` handler, and dismiss via `onMouseLeave` on the wrapper div. Never put interactive event handlers on the scrim — it blocks clicks on sibling elements. + +**101. AI marker format compliance:** The AI assistant uses `[QUESTIONS]`, `[ACTIONS]`, and `[FORK]` markers in responses. Parsed by `unified_chat_service.py` (`_parse_*_marker` functions), returned as structured data in the API response. System prompt in `assistant_chat_service.py` has a final reminder section, and each user message gets an invisible `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. If markers stop appearing: check conversation history stores `display_content` (stripped), verify system prompt final reminder exists, check user message reminder injection is active. + +**102. TaskLane activation must happen in ALL chat response paths:** `AssistantChatPage.tsx` has three code paths calling `sendChatMessage`: `handleSend` (regular messages), `sendPrefill` (dashboard handoff), `handleResumeNew` (resume from concluded session). ALL three must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. Missing this in any path causes TaskLane to not appear on first message. + +**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container. + --- ## RBAC & Permissions @@ -408,7 +387,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi - **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`) - **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated` - **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim` -- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-secondary` (`#848b9b`) → `text-muted` (`#4f5666`) +- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color. - **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`) - **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity - **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74` @@ -513,14 +492,6 @@ When a feature, fix, or significant piece of work is finished and merged/committ --- -## gstack - -Use `/browse` from gstack for **all web browsing** — never use `mcp__claude-in-chrome__*` tools. - -**Available skills:** `/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/design-consultation`, `/review`, `/ship`, `/browse`, `/qa`, `/qa-only`, `/design-review`, `/setup-browser-cookies`, `/retro`, `/investigate`, `/document-release`, `/codex`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, `/gstack-upgrade` - ---- - ## Future Roadmap - **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics @@ -537,6 +508,5 @@ Use `/browse` from gstack for **all web browsing** — never use `mcp__claude-in | Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | | GitHub Issues | `gh issue list --state open` | | Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section | -| Feature Specs | [04-FEATURE-SPECIFICATIONS.md](04-FEATURE-SPECIFICATIONS.md) | | Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | | Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking | diff --git a/backend/alembic/env.py b/backend/alembic/env.py index f3a1014d..7f9503b4 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -25,6 +25,10 @@ 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.models.script_builder_session import ScriptBuilderSession, ScriptBuilderMessage # noqa: F401 +from app.models.session_branch import SessionBranch # noqa: F401 +from app.models.fork_point import ForkPoint # noqa: F401 +from app.models.session_handoff import SessionHandoff # noqa: F401 +from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/067_add_conversational_branching.py b/backend/alembic/versions/067_add_conversational_branching.py new file mode 100644 index 00000000..efe37235 --- /dev/null +++ b/backend/alembic/versions/067_add_conversational_branching.py @@ -0,0 +1,156 @@ +"""Add conversational branching tables and columns. + +Revision ID: 067 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + +revision = "067" +down_revision = "066" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # session_branches + op.create_table( + "session_branches", + 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), + sa.Column("parent_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True), + sa.Column("fork_point_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True), + sa.Column("branch_order", sa.Integer, nullable=False, server_default="1"), + sa.Column("label", sa.String(200), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("status_reason", sa.Text, nullable=True), + sa.Column("status_changed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status_changed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("conversation_messages", JSONB, nullable=False, server_default="[]"), + sa.Column("context_summary", JSONB, nullable=True), + sa.Column("evidence_from_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("evidence_description", sa.Text, nullable=True), + 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.CheckConstraint("status IN ('active', 'dead_end', 'solved', 'untried', 'revived')", name="ck_session_branches_status"), + sa.CheckConstraint("branch_order > 0", name="ck_session_branches_branch_order_positive"), + ) + op.create_index("ix_session_branches_session_id", "session_branches", ["session_id"]) + op.create_index("ix_session_branches_parent_branch_id", "session_branches", ["parent_branch_id"]) + op.create_index("ix_session_branches_session_status", "session_branches", ["session_id", "status"]) + op.create_index("ix_session_branches_session_order", "session_branches", ["session_id", "branch_order"]) + + # fork_points + op.create_table( + "fork_points", + 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), + sa.Column("parent_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False), + sa.Column("trigger_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True), + sa.Column("fork_reason", sa.Text, nullable=False), + sa.Column("options", JSONB, nullable=False, server_default="[]"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_fork_points_session_id", "fork_points", ["session_id"]) + + # session_handoffs + op.create_table( + "session_handoffs", + 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), + sa.Column("handed_off_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("intent", sa.String(20), nullable=False), + sa.Column("source_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("snapshot", JSONB, nullable=False, server_default="{}"), + sa.Column("ai_assessment", sa.Text, nullable=True), + sa.Column("ai_assessment_data", JSONB, nullable=True), + sa.Column("artifacts", JSONB, nullable=True), + sa.Column("engineer_notes", sa.Text, nullable=True), + sa.Column("priority", sa.String(20), nullable=False, server_default="normal"), + sa.Column("claimed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("psa_note_pushed", sa.Boolean, server_default="false"), + sa.Column("psa_note_id", sa.String(100), nullable=True), + sa.Column("notification_sent", sa.Boolean, server_default="false"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.CheckConstraint("intent IN ('park', 'escalate')", name="ck_session_handoffs_intent"), + sa.CheckConstraint("priority IN ('normal', 'elevated')", name="ck_session_handoffs_priority"), + ) + op.create_index("ix_session_handoffs_session_id", "session_handoffs", ["session_id"]) + + # session_resolution_outputs + op.create_table( + "session_resolution_outputs", + 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), + sa.Column("output_type", sa.String(30), nullable=False), + sa.Column("generated_content", sa.Text, nullable=False), + sa.Column("structured_data", JSONB, nullable=True), + sa.Column("edited_content", sa.Text, nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="draft"), + sa.Column("pushed_to", sa.String(50), nullable=True), + sa.Column("pushed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("pushed_reference", sa.String(200), nullable=True), + sa.Column("generated_by_model", sa.String(50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.CheckConstraint("output_type IN ('psa_ticket_notes', 'knowledge_base', 'client_summary')", name="ck_session_resolution_outputs_output_type"), + sa.CheckConstraint("status IN ('draft', 'approved', 'pushed', 'rejected')", name="ck_session_resolution_outputs_status"), + sa.UniqueConstraint("session_id", "output_type", name="uq_session_resolution_session_type"), + ) + op.create_index("ix_session_resolution_outputs_session_id", "session_resolution_outputs", ["session_id"]) + + # ai_sessions: add 5 columns (NO FK on active_branch_id) + op.add_column("ai_sessions", sa.Column("is_branching", sa.Boolean, server_default="false", nullable=False)) + op.add_column("ai_sessions", sa.Column("active_branch_id", UUID(as_uuid=True), nullable=True)) + op.add_column("ai_sessions", sa.Column("handoff_count", sa.Integer, server_default="0", nullable=False)) + op.add_column("ai_sessions", sa.Column("total_active_seconds", sa.Integer, server_default="0", nullable=False)) + op.add_column("ai_sessions", sa.Column("total_parked_seconds", sa.Integer, server_default="0", nullable=False)) + + # ai_session_steps: add 3 columns + update CHECK + op.add_column("ai_session_steps", sa.Column("branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)) + op.add_column("ai_session_steps", sa.Column("is_fork_point", sa.Boolean, server_default="false", nullable=False)) + op.add_column("ai_session_steps", sa.Column("fork_point_id", UUID(as_uuid=True), sa.ForeignKey("fork_points.id", ondelete="SET NULL"), nullable=True)) + op.create_index("ix_ai_session_steps_branch_id", "ai_session_steps", ["branch_id"]) + + op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check") + op.create_check_constraint( + "ck_ai_session_steps_step_type", "ai_session_steps", + "step_type IN ('question', 'action', 'script_generation', 'verification', 'info_request', 'note', 'intake_analysis', 'fork')", + ) + + # file_uploads: add 5 columns + op.add_column("file_uploads", sa.Column("ai_description", sa.Text, nullable=True)) + op.add_column("file_uploads", sa.Column("extracted_content", sa.Text, nullable=True)) + op.add_column("file_uploads", sa.Column("content_summary", sa.Text, nullable=True)) + op.add_column("file_uploads", sa.Column("uploaded_on_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)) + op.add_column("file_uploads", sa.Column("uploaded_at_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)) + + +def downgrade() -> None: + op.drop_column("file_uploads", "uploaded_at_step_id") + op.drop_column("file_uploads", "uploaded_on_branch_id") + op.drop_column("file_uploads", "content_summary") + op.drop_column("file_uploads", "extracted_content") + op.drop_column("file_uploads", "ai_description") + + op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check") + op.create_check_constraint( + "ck_ai_session_steps_step_type", "ai_session_steps", + "step_type IN ('question', 'action', 'script_generation', 'verification', 'info_request', 'note', 'intake_analysis')", + ) + op.drop_index("ix_ai_session_steps_branch_id", "ai_session_steps") + op.drop_column("ai_session_steps", "fork_point_id") + op.drop_column("ai_session_steps", "is_fork_point") + op.drop_column("ai_session_steps", "branch_id") + + op.drop_column("ai_sessions", "total_parked_seconds") + op.drop_column("ai_sessions", "total_active_seconds") + op.drop_column("ai_sessions", "handoff_count") + op.drop_column("ai_sessions", "active_branch_id") + op.drop_column("ai_sessions", "is_branching") + + op.drop_table("session_resolution_outputs") + op.drop_table("session_handoffs") + op.drop_table("fork_points") + op.drop_table("session_branches") diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index c6379220..78909474 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -287,7 +287,7 @@ async def send_chat_message( images = await fetch_upload_images(data.upload_ids, account_id, db) or None try: - ai_content, suggested_flows, session = await unified_chat_service.send_chat_message( + ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data = await unified_chat_service.send_chat_message( session_id=session_id, user_id=user_id, account_id=account_id, @@ -329,6 +329,9 @@ async def send_chat_message( return ChatMessageResponse( content=ai_content, suggested_flows=suggested_flows, + fork=fork_metadata, + actions=actions_data, + questions=questions_data, ) @@ -416,6 +419,15 @@ async def resolve_session( except PermissionError as e: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) + # Generate resolution outputs (branching feature) + try: + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(db) + await gen.generate_all(session_id) + except Exception: + logger.exception(f"Failed to generate resolution outputs for session {session_id}") + # Non-blocking — resolve still succeeds even if output generation fails + await db.commit() return result diff --git a/backend/app/api/endpoints/session_branches.py b/backend/app/api/endpoints/session_branches.py new file mode 100644 index 00000000..e946fefc --- /dev/null +++ b/backend/app/api/endpoints/session_branches.py @@ -0,0 +1,274 @@ +"""Branch management endpoints for conversational branching. + + GET /ai-sessions/{id}/branches — List all branches (tree) + POST /ai-sessions/{id}/branches/fork — Create fork with N branches + PATCH /ai-sessions/{id}/branches/{bid} — Update branch status + POST /ai-sessions/{id}/branches/{bid}/switch — Switch active branch + POST /ai-sessions/{id}/branches/{bid}/revive — Revive dead-end branch + POST /ai-sessions/{id}/branches/{bid}/message — Send message on branch +""" +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_session import AISession +from app.models.session_branch import SessionBranch +from app.services.branch_manager import BranchManager +from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder +from app.services.assistant_chat_service import _call_ai +from app.schemas.session_branch import ( + BranchTreeResponse, + BranchResponse, + BranchUpdate, + ForkCreateRequest, + ForkPointResponse, + BranchSwitchResponse, + ReviveRequest, + BranchMessageRequest, + BranchMessageResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ai-sessions/{session_id}/branches", tags=["session-branches"]) + + +async def _get_user_session( + session_id: UUID, user: User, db: AsyncSession +) -> AISession: + """Fetch session owned by user, or raise 404.""" + result = await db.execute( + select(AISession).where( + AISession.id == session_id, + AISession.user_id == user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return session + + +@router.get("", response_model=BranchTreeResponse) +async def list_branches( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchTreeResponse: + """Get branch tree for a session.""" + session = await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + branches = await manager.get_branch_tree(session_id) + + branch_responses = [] + for b in branches: + branch_responses.append(BranchResponse( + id=b.id, + session_id=b.session_id, + parent_branch_id=b.parent_branch_id, + fork_point_step_id=b.fork_point_step_id, + branch_order=b.branch_order, + label=b.label, + status=b.status, + status_reason=b.status_reason, + status_changed_at=b.status_changed_at, + context_summary=b.context_summary, + evidence_from_branch_id=b.evidence_from_branch_id, + evidence_description=b.evidence_description, + created_at=b.created_at, + updated_at=b.updated_at, + )) + + return BranchTreeResponse( + branches=branch_responses, + active_branch_id=session.active_branch_id, + ) + + +@router.post("/fork", response_model=ForkPointResponse, status_code=status.HTTP_201_CREATED) +async def create_fork( + session_id: UUID, + body: ForkCreateRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ForkPointResponse: + """Create a fork point with N branches.""" + session = await _get_user_session(session_id, current_user, db) + + if session.status not in ("active", "paused"): + raise HTTPException(status_code=400, detail=f"Cannot fork a {session.status} session") + + manager = BranchManager(db) + + # Ensure branching is initialized + if not session.is_branching: + await manager.create_root_branch(session_id) + await db.refresh(session) + + # Use the active branch as parent + parent_branch_id = session.active_branch_id + if not parent_branch_id: + raise HTTPException(status_code=400, detail="No active branch to fork from") + + options = [{"label": o.label, "description": o.description} for o in body.options] + + fork_point, branches = await manager.create_fork( + session_id=session_id, + parent_branch_id=parent_branch_id, + trigger_step_id=None, + fork_reason=body.fork_reason, + options=options, + ) + + await db.commit() + return ForkPointResponse.model_validate(fork_point) + + +@router.patch("/{branch_id}", response_model=BranchResponse) +async def update_branch_status( + session_id: UUID, + branch_id: UUID, + body: BranchUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchResponse: + """Update a branch's status.""" + await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + + try: + branch = await manager.mark_branch_status( + branch_id=branch_id, + status=body.status, + reason=body.status_reason, + user_id=current_user.id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return BranchResponse.model_validate(branch) + + +@router.post("/{branch_id}/switch", response_model=BranchSwitchResponse) +async def switch_branch( + session_id: UUID, + branch_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchSwitchResponse: + """Switch the active branch.""" + await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + + try: + branch = await manager.switch_branch(session_id, branch_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return BranchSwitchResponse( + active_branch_id=branch.id, + branch=BranchResponse.model_validate(branch), + conversation_messages=branch.conversation_messages, + ) + + +@router.post("/{branch_id}/revive", response_model=BranchResponse) +async def revive_branch( + session_id: UUID, + branch_id: UUID, + body: ReviveRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchResponse: + """Revive a dead-end branch with new evidence.""" + await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + + try: + branch = await manager.revive_branch( + branch_id=branch_id, + evidence_from_branch_id=body.evidence_from_branch_id, + evidence_description=body.evidence_description, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return BranchResponse.model_validate(branch) + + +@router.post("/{branch_id}/message", response_model=BranchMessageResponse) +async def send_branch_message( + session_id: UUID, + branch_id: UUID, + body: BranchMessageRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchMessageResponse: + """Send a message on a specific branch.""" + session = await _get_user_session(session_id, current_user, db) + + if session.status not in ("active", "paused"): + raise HTTPException(status_code=400, detail=f"Cannot message a {session.status} session") + + manager = BranchManager(db) + + # Switch to branch if not already active + if session.active_branch_id != branch_id: + await manager.switch_branch(session_id, branch_id) + await db.refresh(session) + + # Get branch + result = await db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + raise HTTPException(status_code=404, detail="Branch not found") + + # Build cross-branch context + sibling_ctx = await manager.build_cross_branch_context(branch_id) + + # Build prompt + builder = BranchAwarePromptBuilder() + session_context = f"Problem: {session.problem_summary or 'Unknown'}. Domain: {session.problem_domain or 'Unknown'}." + prompt_args = builder.build( + branch_messages=branch.conversation_messages, + sibling_summaries=sibling_ctx, + session_context=session_context, + attachments=[], + new_message=body.message, + revival_context=branch.evidence_description if branch.status == "revived" else None, + ) + + # Call AI + ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args) + + # Update branch conversation + msgs = list(branch.conversation_messages or []) + msgs.append({"role": "user", "content": body.message}) + msgs.append({"role": "assistant", "content": ai_content}) + branch.conversation_messages = msgs + + # Update session token counts + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + + # Resume if paused + if session.status == "paused": + session.status = "active" + + await db.commit() + + return BranchMessageResponse( + content=ai_content, + branch_id=branch_id, + ) diff --git a/backend/app/api/endpoints/session_handoffs.py b/backend/app/api/endpoints/session_handoffs.py new file mode 100644 index 00000000..513eefc6 --- /dev/null +++ b/backend/app/api/endpoints/session_handoffs.py @@ -0,0 +1,116 @@ +"""Handoff endpoints — unified park/escalate. + + POST /ai-sessions/{id}/handoff — Create handoff + GET /ai-sessions/{id}/handoffs — Handoff history + POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session + GET /ai-sessions/queue — Team queue +""" +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_session import AISession +from app.models.session_handoff import SessionHandoff +from app.services.handoff_manager import HandoffManager +from app.schemas.session_handoff import ( + HandoffCreateRequest, + HandoffResponse, +) + +logger = logging.getLogger(__name__) + +# Queue endpoint needs its own router (no session_id prefix) +queue_router = APIRouter(prefix="/ai-sessions", tags=["session-handoffs"]) + +# Session-scoped endpoints +router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"]) + + +@router.post("/handoff", response_model=HandoffResponse, status_code=status.HTTP_201_CREATED) +async def create_handoff( + session_id: UUID, + body: HandoffCreateRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> HandoffResponse: + """Create a handoff (park or escalate).""" + result = await db.execute( + select(AISession).where( + AISession.id == session_id, + AISession.user_id == current_user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + manager = HandoffManager(db) + try: + handoff = await manager.create_handoff( + session_id=session_id, + intent=body.intent, + engineer_notes=body.engineer_notes, + user_id=current_user.id, + priority=body.priority, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + await db.commit() + return HandoffResponse.model_validate(handoff) + + +@router.get("/handoffs", response_model=list[HandoffResponse]) +async def list_handoffs( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[HandoffResponse]: + """Get handoff history for a session.""" + result = await db.execute( + select(SessionHandoff) + .where(SessionHandoff.session_id == session_id) + .order_by(SessionHandoff.created_at.desc()) + ) + handoffs = result.scalars().all() + return [HandoffResponse.model_validate(h) for h in handoffs] + + +@router.post("/handoffs/{handoff_id}/claim", response_model=HandoffResponse) +async def claim_handoff( + session_id: UUID, + handoff_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> HandoffResponse: + """Claim a handed-off session.""" + manager = HandoffManager(db) + try: + handoff = await manager.claim_session( + handoff_id=handoff_id, + claiming_user_id=current_user.id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return HandoffResponse.model_validate(handoff) + + +@queue_router.get("/queue") +async def get_queue( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[dict]: + """Get team queue of parked + escalated sessions.""" + manager = HandoffManager(db) + return await manager.get_queue( + team_id=current_user.team_id, + account_id=current_user.account_id, + ) diff --git a/backend/app/api/endpoints/session_resolutions.py b/backend/app/api/endpoints/session_resolutions.py new file mode 100644 index 00000000..3a13dc9b --- /dev/null +++ b/backend/app/api/endpoints/session_resolutions.py @@ -0,0 +1,80 @@ +"""Resolution output endpoints.""" +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.session_resolution_output import SessionResolutionOutput +from app.services.resolution_output_generator import ResolutionOutputGenerator +from app.schemas.session_resolution import ( + ResolutionOutputResponse, + ResolutionOutputEditRequest, + ResolutionOutputPushRequest, + ResolutionOutputPushResponse, + AllResolutionOutputsResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-resolutions"]) + + +@router.get("/outputs", response_model=AllResolutionOutputsResponse) +async def get_outputs( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> AllResolutionOutputsResponse: + result = await db.execute( + select(SessionResolutionOutput) + .where(SessionResolutionOutput.session_id == session_id) + .order_by(SessionResolutionOutput.output_type) + ) + outputs = result.scalars().all() + return AllResolutionOutputsResponse( + outputs=[ResolutionOutputResponse.model_validate(o) for o in outputs] + ) + + +@router.patch("/outputs/{output_id}", response_model=ResolutionOutputResponse) +async def edit_output( + session_id: UUID, + output_id: UUID, + body: ResolutionOutputEditRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResolutionOutputResponse: + gen = ResolutionOutputGenerator(db) + try: + output = await gen.edit_output(output_id, body.edited_content) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + await db.commit() + return ResolutionOutputResponse.model_validate(output) + + +@router.post("/outputs/{output_id}/push", response_model=ResolutionOutputPushResponse) +async def push_output( + session_id: UUID, + output_id: UUID, + body: ResolutionOutputPushRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResolutionOutputPushResponse: + gen = ResolutionOutputGenerator(db) + try: + output = await gen.push_output(output_id, body.destination) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + await db.commit() + return ResolutionOutputPushResponse( + output_id=output.id, + status=output.status, + pushed_to=output.pushed_to or body.destination, + pushed_reference=output.pushed_reference, + ) diff --git a/backend/app/api/endpoints/uploads.py b/backend/app/api/endpoints/uploads.py index 72f61c8a..f3234144 100644 --- a/backend/app/api/endpoints/uploads.py +++ b/backend/app/api/endpoints/uploads.py @@ -35,6 +35,60 @@ def _check_storage_configured() -> None: ) +async def _generate_ai_description(upload_id: UUID, file_data: bytes, content_type: str) -> None: + """Background task: generate AI description for uploaded file.""" + try: + from app.core.database import async_session_maker + from app.services.assistant_chat_service import _call_ai + import base64 + + async with async_session_maker() as db: + result = await db.execute( + select(FileUpload).where(FileUpload.id == upload_id) + ) + upload = result.scalar_one_or_none() + if not upload: + return + + if content_type.startswith("image/"): + b64_data = base64.b64encode(file_data).decode("utf-8") + description, _, _ = await _call_ai( + system_base="You are a technical image analyst for IT troubleshooting.", + rag_context="", + history=[], + new_message="Describe this image in one sentence for a troubleshooting context log.", + images=[{"media_type": content_type, "data": b64_data}], + max_tokens=100, + ) + upload.ai_description = description + elif content_type.startswith("text/") or content_type in ( + "application/json", "application/xml", "application/yaml", + ): + try: + text_content = file_data.decode("utf-8") + except UnicodeDecodeError: + text_content = file_data.decode("latin-1") + + upload.extracted_content = text_content[:10000] + + if len(text_content) > 2000: + summary, _, _ = await _call_ai( + system_base="You are a technical log/config analyst.", + rag_context="", + history=[], + new_message=f"Summarize this file content in 2-3 sentences:\n\n{text_content[:5000]}", + max_tokens=200, + ) + upload.content_summary = summary + upload.ai_description = summary + else: + upload.ai_description = f"Text file: {upload.filename}" + + await db.commit() + except Exception: + logger.exception(f"Failed to generate AI description for upload {upload_id}") + + @router.post("", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED) @limiter.limit("10/minute") async def upload_file( @@ -113,6 +167,11 @@ async def upload_file( await db.commit() await db.refresh(upload) + import asyncio + asyncio.create_task( + _generate_ai_description(upload.id, file_data, content_type) + ) + presigned_url = storage_service.get_presigned_url(upload.storage_key) return FileUploadResponse( diff --git a/backend/app/api/router.py b/backend/app/api/router.py index d98042b7..d588afc9 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -30,6 +30,9 @@ from app.api.endpoints import admin_gallery from app.api.endpoints import uploads from app.api.endpoints import script_builder from app.api.endpoints import beta_feedback +from app.api.endpoints import session_branches +from app.api.endpoints import session_handoffs +from app.api.endpoints import session_resolutions api_router = APIRouter() @@ -76,6 +79,8 @@ 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(session_handoffs.queue_router) # Must be before ai_sessions to avoid /{session_id} conflict +api_router.include_router(session_resolutions.router) # Must be before ai_sessions to avoid /{session_id} conflict api_router.include_router(ai_sessions.router) api_router.include_router(flow_proposals.router) api_router.include_router(flowpilot_analytics.router) @@ -85,3 +90,5 @@ api_router.include_router(admin_gallery.router) api_router.include_router(uploads.router) api_router.include_router(script_builder.router) api_router.include_router(beta_feedback.router) +api_router.include_router(session_branches.router) +api_router.include_router(session_handoffs.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 07db1ba8..fd3a754a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -50,6 +50,10 @@ from .psa_activity_log import PsaActivityLog from .file_upload import FileUpload from .ai_session_embedding import AISessionEmbedding from .beta_feedback import BetaFeedback +from .session_branch import SessionBranch +from .fork_point import ForkPoint +from .session_handoff import SessionHandoff +from .session_resolution_output import SessionResolutionOutput __all__ = [ "User", @@ -114,4 +118,8 @@ __all__ = [ "FileUpload", "AISessionEmbedding", "BetaFeedback", + "SessionBranch", + "ForkPoint", + "SessionHandoff", + "SessionResolutionOutput", ] diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py index 721924ad..3cc3f11d 100644 --- a/backend/app/models/ai_session.py +++ b/backend/app/models/ai_session.py @@ -20,6 +20,10 @@ if TYPE_CHECKING: from app.models.account import Account from app.models.tree import Tree from app.models.psa_connection import PsaConnection + from app.models.session_branch import SessionBranch + from app.models.fork_point import ForkPoint + from app.models.session_handoff import SessionHandoff + from app.models.session_resolution_output import SessionResolutionOutput class AISession(Base): @@ -206,6 +210,28 @@ class AISession(Base): comment="Full LLM message history for context continuity", ) + # ── Branching ── + is_branching: Mapped[bool] = mapped_column( + default=False, + comment="Whether conversational branching is active for this session", + ) + active_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), nullable=True, + comment="Currently viewed branch. No FK — soft pointer to avoid circular FK with session_branches", + ) + handoff_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Number of times this session has been handed off", + ) + total_active_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Cumulative active time in seconds", + ) + total_parked_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Cumulative parked time in seconds", + ) + # ── Relationships ── user: Mapped["User"] = relationship("User", foreign_keys=[user_id]) account: Mapped["Account"] = relationship("Account") @@ -218,3 +244,19 @@ class AISession(Base): cascade="all, delete-orphan", order_by="AISessionStep.step_order", ) + branches: Mapped[list["SessionBranch"]] = relationship( + "SessionBranch", + foreign_keys="SessionBranch.session_id", + back_populates="session", + cascade="all, delete-orphan", + order_by="SessionBranch.branch_order", + ) + handoffs: Mapped[list["SessionHandoff"]] = relationship( + "SessionHandoff", + cascade="all, delete-orphan", + order_by="SessionHandoff.created_at", + ) + resolution_outputs: Mapped[list["SessionResolutionOutput"]] = relationship( + "SessionResolutionOutput", + cascade="all, delete-orphan", + ) diff --git a/backend/app/models/ai_session_step.py b/backend/app/models/ai_session_step.py index 413f142c..ac08da72 100644 --- a/backend/app/models/ai_session_step.py +++ b/backend/app/models/ai_session_step.py @@ -16,6 +16,8 @@ from app.core.database import Base if TYPE_CHECKING: from app.models.ai_session import AISession from app.models.script_template import ScriptGeneration + from app.models.session_branch import SessionBranch + from app.models.fork_point import ForkPoint class AISessionStep(Base): @@ -34,7 +36,7 @@ class AISessionStep(Base): __table_args__ = ( CheckConstraint( "step_type IN ('question', 'action', 'script_generation', 'verification', " - "'info_request', 'note', 'intake_analysis')", + "'info_request', 'note', 'intake_analysis', 'fork')", name="ck_ai_session_steps_step_type", ), ) @@ -119,6 +121,24 @@ class AISessionStep(Base): Integer, nullable=False, default=0, ) + # ── Branching ── + branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="NULL = pre-branching/root messages", + ) + is_fork_point: Mapped[bool] = mapped_column( + default=False, + comment="Whether this step triggered a fork", + ) + fork_point_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("fork_points.id", ondelete="SET NULL"), + nullable=True, + ) + # ── Timestamps ── created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) diff --git a/backend/app/models/file_upload.py b/backend/app/models/file_upload.py index 6846b1a3..208c35b5 100644 --- a/backend/app/models/file_upload.py +++ b/backend/app/models/file_upload.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime, timezone from typing import Optional -from sqlalchemy import String, Integer, DateTime, ForeignKey +from sqlalchemy import String, Integer, DateTime, ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID @@ -30,3 +30,27 @@ class FileUpload(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) + + # ── AI description + branching context ── + ai_description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="AI-generated one-sentence description of the file", + ) + extracted_content: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="Extracted text from logs/configs", + ) + content_summary: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="AI summary for long text files (>2000 tokens)", + ) + uploaded_on_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="SET NULL"), + nullable=True, + ) + uploaded_at_step_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("ai_session_steps.id", ondelete="SET NULL"), + nullable=True, + ) diff --git a/backend/app/models/fork_point.py b/backend/app/models/fork_point.py new file mode 100644 index 00000000..a5700774 --- /dev/null +++ b/backend/app/models/fork_point.py @@ -0,0 +1,35 @@ +"""Fork point model — captures decision points where a session branches.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import Text, DateTime, ForeignKey +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.session_branch import SessionBranch + from app.models.ai_session_step import AISessionStep + + +class ForkPoint(Base): + """A decision point where a session forks into multiple branches. + options JSONB: [{label, description, branch_id, status}] + """ + __tablename__ = "fork_points" + + 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) + parent_branch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False) + trigger_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True) + fork_reason: Mapped[str] = mapped_column(Text, nullable=False) + options: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list, comment="[{label, description, branch_id, status}]") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + # Relationships + session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id]) + parent_branch: Mapped["SessionBranch"] = relationship("SessionBranch", foreign_keys=[parent_branch_id]) + trigger_step: Mapped[Optional["AISessionStep"]] = relationship("AISessionStep", foreign_keys=[trigger_step_id]) diff --git a/backend/app/models/session_branch.py b/backend/app/models/session_branch.py new file mode 100644 index 00000000..ab6cc50e --- /dev/null +++ b/backend/app/models/session_branch.py @@ -0,0 +1,58 @@ +"""Session branch model — represents a diagnostic hypothesis path within a FlowPilot session.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, CheckConstraint, Index +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.ai_session_step import AISessionStep + from app.models.user import User + + +class SessionBranch(Base): + """A diagnostic branch within a FlowPilot session.""" + __tablename__ = "session_branches" + __table_args__ = ( + CheckConstraint( + "status IN ('active', 'dead_end', 'solved', 'untried', 'revived')", + name="ck_session_branches_status", + ), + CheckConstraint( + "branch_order > 0", + name="ck_session_branches_branch_order_positive", + ), + Index("ix_session_branches_session_id", "session_id"), + Index("ix_session_branches_parent_branch_id", "parent_branch_id"), + Index("ix_session_branches_session_status", "session_id", "status"), + Index("ix_session_branches_session_order", "session_id", "branch_order"), + ) + + 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) + parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True) + fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True) + branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + label: Mapped[str] = mapped_column(String(200), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + status_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + status_changed_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list, comment="LLM message history scoped to this branch") + context_summary: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="{tried: [], concluded: str, artifacts: []}") + evidence_from_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True) + evidence_description: Mapped[Optional[str]] = mapped_column(Text, nullable=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)) + + # Relationships + session: Mapped["AISession"] = relationship("AISession", foreign_keys="[SessionBranch.session_id]", back_populates="branches") + parent_branch: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", remote_side="SessionBranch.id", foreign_keys=[parent_branch_id]) + fork_point_step: Mapped[Optional["AISessionStep"]] = relationship("AISessionStep", foreign_keys=[fork_point_step_id]) + status_changed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[status_changed_by]) + evidence_source: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", remote_side="SessionBranch.id", foreign_keys=[evidence_from_branch_id]) diff --git a/backend/app/models/session_handoff.py b/backend/app/models/session_handoff.py new file mode 100644 index 00000000..fe679353 --- /dev/null +++ b/backend/app/models/session_handoff.py @@ -0,0 +1,50 @@ +"""Session handoff model — unified park/escalate with history.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import String, Text, 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.ai_session import AISession + from app.models.session_branch import SessionBranch + from app.models.user import User + + +class SessionHandoff(Base): + """A handoff event — either parking or escalating a session. + Dual-writes to ai_sessions.escalation_package for backward compat. + """ + __tablename__ = "session_handoffs" + __table_args__ = ( + CheckConstraint("intent IN ('park', 'escalate')", name="ck_session_handoffs_intent"), + CheckConstraint("priority IN ('normal', 'elevated')", name="ck_session_handoffs_priority"), + ) + + 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) + handed_off_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + intent: Mapped[str] = mapped_column(String(20), nullable=False) + source_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict, comment="Branch map, status, next step, waiting on, watch out") + ai_assessment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ai_assessment_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="{likely_cause, suggested_steps, confidence}") + artifacts: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSONB, nullable=True, comment="[{name, type, reference}]") + engineer_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") + claimed_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + claimed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + psa_note_pushed: Mapped[bool] = mapped_column(Boolean, default=False) + psa_note_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + notification_sent: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + # Relationships + session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id]) + handed_off_by_user: Mapped["User"] = relationship("User", foreign_keys=[handed_off_by]) + source_branch: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", foreign_keys=[source_branch_id]) + claimed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[claimed_by]) diff --git a/backend/app/models/session_resolution_output.py b/backend/app/models/session_resolution_output.py new file mode 100644 index 00000000..83803593 --- /dev/null +++ b/backend/app/models/session_resolution_output.py @@ -0,0 +1,39 @@ +"""Session resolution output model — three deliverables generated on resolve.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any + +from sqlalchemy import String, Text, DateTime, ForeignKey, CheckConstraint, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB + +from app.core.database import Base + + +class SessionResolutionOutput(Base): + """One of three resolution deliverables: PSA ticket notes, KB article, client summary. + UNIQUE(session_id, output_type) + upsert so outputs can be regenerated. + """ + __tablename__ = "session_resolution_outputs" + __table_args__ = ( + CheckConstraint("output_type IN ('psa_ticket_notes', 'knowledge_base', 'client_summary')", name="ck_session_resolution_outputs_output_type"), + CheckConstraint("status IN ('draft', 'approved', 'pushed', 'rejected')", name="ck_session_resolution_outputs_status"), + UniqueConstraint("session_id", "output_type", name="uq_session_resolution_session_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) + output_type: Mapped[str] = mapped_column(String(30), nullable=False) + generated_content: Mapped[str] = mapped_column(Text, nullable=False) + structured_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="For KB: {symptoms, root_cause, steps, tags}") + edited_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft") + pushed_to: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + pushed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + pushed_reference: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + generated_by_model: Mapped[str] = mapped_column(String(50), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + session = relationship("AISession", foreign_keys="SessionResolutionOutput.session_id") diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 973de98c..4037f38a 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -228,6 +228,8 @@ class AISessionDetail(AISessionSummary): ticket_data: dict[str, Any] | None = None steps: list[AISessionStepResponse] = [] conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here + is_branching: bool = False + active_branch_id: str | None = None model_config = {"from_attributes": True} @@ -248,10 +250,40 @@ class ChatMessageRequest(BaseModel): upload_ids: list[UUID] = Field(default_factory=list, max_length=10) +class ForkBranchInfo(BaseModel): + """Branch info returned when a fork is created.""" + branch_id: str + label: str + + +class ForkMetadata(BaseModel): + """Metadata returned when the AI suggests a diagnostic fork.""" + fork_point_id: str + fork_reason: str + branches: list[ForkBranchInfo] + active_branch_id: str + + +class ActionItem(BaseModel): + """A single action item for the engineer.""" + label: str + command: str | None = None + description: str = "" + + +class QuestionItem(BaseModel): + """A question the AI needs answered by the engineer.""" + text: str + context: str = "" + + class ChatMessageResponse(BaseModel): """AI response to a chat message.""" content: str suggested_flows: list[dict[str, Any]] = [] + fork: ForkMetadata | None = None + actions: list[ActionItem] | None = None + questions: list[QuestionItem] | None = None class AISessionSearchResult(BaseModel): diff --git a/backend/app/schemas/session_branch.py b/backend/app/schemas/session_branch.py new file mode 100644 index 00000000..deebaca2 --- /dev/null +++ b/backend/app/schemas/session_branch.py @@ -0,0 +1,83 @@ +"""Pydantic schemas for session branches and fork points.""" +from __future__ import annotations +from typing import Any +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel, Field + + +class BranchCreate(BaseModel): + label: str = Field(..., max_length=200) + status: str = "untried" + + +class BranchUpdate(BaseModel): + status: str = Field(..., pattern="^(active|dead_end|solved|untried|revived)$") + status_reason: str | None = None + + +class BranchResponse(BaseModel): + id: UUID + session_id: UUID + parent_branch_id: UUID | None + fork_point_step_id: UUID | None + branch_order: int + label: str + status: str + status_reason: str | None + status_changed_at: datetime | None + context_summary: dict[str, Any] | None + evidence_from_branch_id: UUID | None + evidence_description: str | None + step_count: int = 0 + created_at: datetime + updated_at: datetime + model_config = {"from_attributes": True} + + +class BranchTreeResponse(BaseModel): + branches: list[BranchResponse] + active_branch_id: UUID | None + + +class ForkOption(BaseModel): + label: str = Field(..., max_length=200) + description: str = Field(..., max_length=500) + + +class ForkCreateRequest(BaseModel): + fork_reason: str = Field(..., min_length=5, max_length=2000) + options: list[ForkOption] = Field(..., min_length=2, max_length=10) + + +class ForkPointResponse(BaseModel): + id: UUID + session_id: UUID + parent_branch_id: UUID + trigger_step_id: UUID | None + fork_reason: str + options: list[dict[str, Any]] + created_at: datetime + model_config = {"from_attributes": True} + + +class BranchSwitchResponse(BaseModel): + active_branch_id: UUID + branch: BranchResponse + conversation_messages: list[dict[str, Any]] + + +class ReviveRequest(BaseModel): + evidence_from_branch_id: UUID + evidence_description: str = Field(..., min_length=5, max_length=2000) + + +class BranchMessageRequest(BaseModel): + message: str = Field(..., min_length=1, max_length=8000) + upload_ids: list[UUID] = Field(default_factory=list, max_length=10) + + +class BranchMessageResponse(BaseModel): + content: str + branch_id: UUID + step_id: UUID | None = None diff --git a/backend/app/schemas/session_handoff.py b/backend/app/schemas/session_handoff.py new file mode 100644 index 00000000..da1a52cb --- /dev/null +++ b/backend/app/schemas/session_handoff.py @@ -0,0 +1,57 @@ +"""Pydantic schemas for session handoffs.""" +from __future__ import annotations +from typing import Any +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel, Field + + +class HandoffCreateRequest(BaseModel): + intent: str = Field(..., pattern="^(park|escalate)$") + engineer_notes: str | None = None + priority: str = Field("normal", pattern="^(normal|elevated)$") + + +class HandoffResponse(BaseModel): + id: UUID + session_id: UUID + handed_off_by: UUID + intent: str + source_branch_id: UUID | None + snapshot: dict[str, Any] + ai_assessment: str | None + ai_assessment_data: dict[str, Any] | None + artifacts: list[dict[str, Any]] | None + engineer_notes: str | None + priority: str + claimed_by: UUID | None + claimed_at: datetime | None + psa_note_pushed: bool + notification_sent: bool + created_at: datetime + model_config = {"from_attributes": True} + + +class HandoffClaimRequest(BaseModel): + pass + + +class HandoffBriefingResponse(BaseModel): + briefing: str + handoff: HandoffResponse + + +class QueueItemResponse(BaseModel): + handoff_id: UUID + session_id: UUID + intent: str + problem_summary: str | None + problem_domain: str | None + priority: str + handed_off_by_name: str | None + engineer_notes: str | None + branch_count: int = 0 + created_at: datetime + claimed_by: UUID | None + claimed_at: datetime | None + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/session_resolution.py b/backend/app/schemas/session_resolution.py new file mode 100644 index 00000000..126c93fb --- /dev/null +++ b/backend/app/schemas/session_resolution.py @@ -0,0 +1,42 @@ +"""Pydantic schemas for session resolution outputs.""" +from __future__ import annotations +from typing import Any +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel, Field + + +class ResolutionOutputResponse(BaseModel): + id: UUID + session_id: UUID + output_type: str + generated_content: str + structured_data: dict[str, Any] | None + edited_content: str | None + status: str + pushed_to: str | None + pushed_at: datetime | None + pushed_reference: str | None + generated_by_model: str + created_at: datetime + updated_at: datetime + model_config = {"from_attributes": True} + + +class ResolutionOutputEditRequest(BaseModel): + edited_content: str = Field(..., min_length=1) + + +class ResolutionOutputPushRequest(BaseModel): + destination: str = Field(..., pattern="^(psa|kb_library|clipboard|email)$") + + +class ResolutionOutputPushResponse(BaseModel): + output_id: UUID + status: str + pushed_to: str + pushed_reference: str | None = None + + +class AllResolutionOutputsResponse(BaseModel): + outputs: list[ResolutionOutputResponse] diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 87a92e8b..49c44ffe 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -33,28 +33,59 @@ deep expertise across the MSP technology stack: - PowerShell scripting and automation - Security: MFA, Conditional Access, EDR, backup/DR -## How to Answer -- **Be direct and actionable.** Engineers are mid-ticket — lead with the fix or next \ -diagnostic step, then explain why in one sentence if helpful. Skip background unless asked. -- **Include specifics.** Exact commands, registry paths, config values, port numbers. \ -Vague advice wastes time. -- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \ -say so upfront — before the command. -- **Use structured formatting.** Bullet points for steps, code blocks for commands, \ -bold for key terms. Engineers scan, they don't read essays. -- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \ -where to verify (vendor docs, a specific KB article) rather than guessing. +## RESPONSE FORMAT — READ THIS FIRST -## How to Ask Questions -- **Default to a single focused question.** Ask what you need to know right now to make progress. -- **Use contextual bullets sparingly.** If the question could be ambiguous (e.g., "what error?" \ -when there are multiple common patterns), add 2-3 sub-bullets to help the engineer recognize \ -what you're asking for — but keep it short. -- **Multiple questions only when blocking.** If you genuinely cannot proceed without knowing \ -two things (e.g., both the error message AND which users are affected), preface it clearly: \ -"Before continuing troubleshooting, I need to know: 1) [question], 2) [question]." Use this rarely. -- **Avoid interrogation mode.** Don't fire off 5 questions in a row. Get one answer, make \ -progress, then ask the next question if needed. +Every response you write MUST follow this exact structure: + +1. **1-3 sentences of analysis** (what the symptoms tell you) +2. **[QUESTIONS] marker** with 1-3 questions for the engineer (if you need info) +3. **[ACTIONS] marker** with 1-4 diagnostic commands to run (if applicable) + +You MUST include at least one marker ([QUESTIONS] or [ACTIONS]) in every response. \ +A response with only prose and no markers is INVALID and will break the UI. + +### Complete example of a correct first response: + +User: "Outlook disconnects every 10-15 min, Teams drops too, only this one user, WiFi" + +Your response: + +Both apps dropping on the same 10-15 min cycle on WiFi points to a network-layer \ +timeout — likely DHCP lease renewal, AP roaming, or NIC power management. Single-user \ +scope narrows it to this endpoint. + +[QUESTIONS] +[{"text": "Is this user on a laptop or desktop?", "context": "Laptops have power management and docking transitions that cause WiFi drops"}, +{"text": "Are they on corporate WiFi or working from home?", "context": "Corporate WiFi with multiple APs can cause roaming disconnects"}] +[/QUESTIONS] + +[ACTIONS] +[{"label": "Check DHCP lease time", "command": "ipconfig /all | Select-String -Pattern 'DHCP|IPv4|Lease|Gateway'", "description": "Short lease times (under 1 hour) cause brief drops at renewal"}, +{"label": "Check NIC power management", "command": "Get-NetAdapterPowerManagement | Select Name, AllowComputerToTurnOffDevice", "description": "If True, Windows is likely killing the adapter during idle periods"}, +{"label": "Check WiFi signal and AP", "command": "netsh wlan show interfaces", "description": "Shows current BSSID, signal strength, and whether they are bouncing between APs"}] +[/ACTIONS] + +### Rules + +**Prose rules:** +- MAXIMUM 3 sentences. No numbered lists. No "Most likely causes: 1... 2... 3..." +- Never narrate intentions ("I want to check...", "Let's get eyes on..."). Just include markers. +- Be specific: exact commands, registry paths, port numbers. +- Warn before destructive actions. + +**[QUESTIONS] marker format:** +- JSON array of objects with `text` (required) and `context` (optional, 1 sentence) +- 1-3 questions per response +- Do NOT ask questions inline in your prose. ALL questions go in the marker. + +**[ACTIONS] marker format:** +- JSON array of objects with `label` (required), `command` (optional), `description` (required) +- 1-4 action items per response +- Commands should be PowerShell unless context indicates Linux/Mac +- For GUI-only steps, omit `command` + +**Both markers are stripped from display** — the engineer sees them as interactive UI cards, \ +not raw JSON. Put analysis BEFORE markers. Markers go at the END of your response. ## Using the Team's Flow Library Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \ @@ -73,10 +104,57 @@ When an image is attached, analyze it carefully. Screenshots of error messages, config panels, event viewer logs, and network diagrams are common in MSP work. \ Describe what you see and use the visual information to inform your troubleshooting advice. +## Diagnostic Forking +When symptoms point to 2+ different subsystems or root causes, you MUST create a diagnostic \ +fork. Forking tracks the different investigation paths in the background — the engineer \ +sees them in a sidebar and can switch between them anytime. + +**IMPORTANT: Forking is invisible to the engineer in the conversation.** You do NOT mention \ +forking, branching, or paths to the engineer. You just continue the conversation naturally. \ +The fork marker is metadata that the system uses behind the scenes. + +**You MUST fork when:** +- Symptoms affect multiple applications or layers (e.g., Outlook AND Teams dropping) +- The problem could be endpoint-side OR infrastructure-side +- Multiple well-known causes match the exact same symptom pattern + +**Do NOT fork when:** +- One cause is clearly >80% likely — just investigate that first +- A single yes/no question would eliminate all but one possibility + +**Fork response format:** +Even when forking, you MUST still follow the RESPONSE FORMAT above. Your response \ +must include [QUESTIONS] and/or [ACTIONS] markers — the fork marker is IN ADDITION \ +to those, not a replacement. Do NOT ask questions in prose — put them in [QUESTIONS]. + +Structure: 1-3 sentences of analysis → [QUESTIONS] and/or [ACTIONS] → [FORK] at the very end. + +Example flow: +- Engineer: "Outlook disconnects every 15 min, Teams drops too, only one user" +- You: "The 10-15 min pattern with both apps points to network layer." +- Then: [QUESTIONS] marker, then [ACTIONS] marker, then [FORK] marker last. + +The fork marker is stripped from display — the engineer never sees it. \ +The system creates branches silently. Based on the engineer's answer, you pick \ +the most relevant branch to investigate first. + +To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers: + +[FORK] +{"fork_reason": "Brief reason", "options": [{"label": "Short name", "description": "One sentence"}, {"label": "Another", "description": "One sentence"}]} +[/FORK] + +2-4 options. Never mention "fork", "branch", or "path" in your visible text. + ## Boundaries - Stay focused on IT infrastructure, systems administration, and MSP operations. - If a question is clearly outside your domain, say so briefly and redirect. - Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so. + +## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE +Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ +No exceptions. Not even when forking. A response without at least one of these markers \ +will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional. """ @@ -174,6 +252,16 @@ async def _call_anthropic_cached( } # Add the new user message (uncached — it's new each turn) + # Append a format reminder to the user message so the model sees it + # immediately before generating. This is invisible to the user (stripped + # before storage) but critical for structured output compliance. + format_reminder = ( + "\n\n[SYSTEM: Remember — your response MUST end with [QUESTIONS] " + "and/or [ACTIONS] markers containing valid JSON arrays. " + "Responses without markers break the UI.]" + ) + reminded_message = new_message + format_reminder + # If images are attached, build multimodal content blocks if images: content_blocks: list[dict[str, Any]] = [] @@ -186,10 +274,10 @@ async def _call_anthropic_cached( "data": img["data"], }, }) - content_blocks.append({"type": "text", "text": new_message}) + content_blocks.append({"type": "text", "text": reminded_message}) messages.append({"role": "user", "content": content_blocks}) else: - messages.append({"role": "user", "content": new_message}) + messages.append({"role": "user", "content": reminded_message}) # MCP server config (optional — controlled by settings) mcp_servers = anthropic.NOT_GIVEN diff --git a/backend/app/services/branch_aware_prompt_builder.py b/backend/app/services/branch_aware_prompt_builder.py new file mode 100644 index 00000000..055b834f --- /dev/null +++ b/backend/app/services/branch_aware_prompt_builder.py @@ -0,0 +1,58 @@ +"""Branch-aware prompt builder — assembles AI context with cross-branch awareness. + +Pure function: takes data, returns dict matching _call_ai parameter names. +No DB access, no LLM calls. The caller pre-fetches all data. + +Return keys: system_base, rag_context, history, new_message, images +- system_base: stable system prompt (cached by Anthropic) +- rag_context: cross-branch summaries + attachment descriptions (NOT cached) +""" +from typing import Any + +from app.services.assistant_chat_service import ASSISTANT_SYSTEM_PROMPT + + +class BranchAwarePromptBuilder: + """Assembles prompt components for branch-aware AI calls.""" + + def build( + self, + branch_messages: list[dict[str, Any]], + sibling_summaries: str, + session_context: str, + attachments: list[dict[str, Any]], + new_message: str, + revival_context: str | None = None, + token_budget: int = 100_000, + ) -> dict[str, Any]: + """Build prompt components for _call_ai. + + Returns dict with keys: system_base, rag_context, history, new_message, images. + """ + # 1. system_base — stable, cached across turns + system_base = ASSISTANT_SYSTEM_PROMPT + "\n\n## Session Context\n" + session_context + + # 2. rag_context — changes per query, NOT cached + rag_parts = [] + if revival_context: + rag_parts.append(f"\n## Branch Revival\n{revival_context}") + if sibling_summaries: + rag_parts.append(sibling_summaries) + rag_context = "\n".join(rag_parts) + + # 3. history — branch messages filtered to user/assistant + history = [] + for msg in branch_messages: + if msg.get("role") in ("user", "assistant"): + history.append({"role": msg["role"], "content": msg["content"]}) + + # 4. images + images = attachments if attachments else None + + return { + "system_base": system_base, + "rag_context": rag_context, + "history": history, + "new_message": new_message, + "images": images, + } diff --git a/backend/app/services/branch_manager.py b/backend/app/services/branch_manager.py new file mode 100644 index 00000000..8dba3fa4 --- /dev/null +++ b/backend/app/services/branch_manager.py @@ -0,0 +1,238 @@ +"""Branch lifecycle management for conversational branching.""" +import uuid +import logging +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ai_session import AISession +from app.models.ai_session_step import AISessionStep +from app.models.session_branch import SessionBranch +from app.models.fork_point import ForkPoint + +logger = logging.getLogger(__name__) + + +class BranchManager: + """Branch lifecycle management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_root_branch(self, session_id: UUID) -> SessionBranch: + """Create the root branch, copy conversation_messages, set is_branching=True.""" + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + root = SessionBranch( + id=uuid.uuid4(), + session_id=session_id, + parent_branch_id=None, + branch_order=1, + label="Root", + status="active", + conversation_messages=list(session.conversation_messages or []), + ) + self.db.add(root) + + session.is_branching = True + session.active_branch_id = root.id + + await self.db.flush() + return root + + async def create_fork( + self, + session_id: UUID, + parent_branch_id: UUID, + trigger_step_id: UUID | None, + fork_reason: str, + options: list[dict[str, str]], + ) -> tuple[ForkPoint, list[SessionBranch]]: + """Create a fork point with N branches.""" + branch_ids = [uuid.uuid4() for _ in options] + + fork_options = [] + for i, opt in enumerate(options): + fork_options.append({ + "label": opt["label"], + "description": opt["description"], + "branch_id": str(branch_ids[i]), + "status": "untried", + }) + + fork_point = ForkPoint( + id=uuid.uuid4(), + session_id=session_id, + parent_branch_id=parent_branch_id, + trigger_step_id=trigger_step_id, + fork_reason=fork_reason, + options=fork_options, + ) + self.db.add(fork_point) + + # Get parent branch messages for context inheritance + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == parent_branch_id) + ) + parent = result.scalar_one_or_none() + parent_messages = list(parent.conversation_messages or []) if parent else [] + + branches = [] + for i, opt in enumerate(options): + branch = SessionBranch( + id=branch_ids[i], + session_id=session_id, + parent_branch_id=parent_branch_id, + fork_point_step_id=trigger_step_id, + branch_order=i + 1, + label=opt["label"], + status="untried", + conversation_messages=parent_messages, + ) + self.db.add(branch) + branches.append(branch) + + # Mark trigger step as fork point + if trigger_step_id: + step_result = await self.db.execute( + select(AISessionStep).where(AISessionStep.id == trigger_step_id) + ) + step = step_result.scalar_one_or_none() + if step: + step.is_fork_point = True + step.fork_point_id = fork_point.id + + await self.db.flush() + return fork_point, branches + + async def switch_branch(self, session_id: UUID, target_branch_id: UUID) -> SessionBranch: + """Switch the active branch for a session.""" + result = await self.db.execute( + select(SessionBranch).where( + SessionBranch.id == target_branch_id, + SessionBranch.session_id == session_id, + ) + ) + branch = result.scalar_one_or_none() + if not branch: + raise ValueError(f"Branch {target_branch_id} not found in session {session_id}") + + session_result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = session_result.scalar_one() + session.active_branch_id = target_branch_id + + if branch.status == "untried": + branch.status = "active" + branch.status_changed_at = datetime.now(timezone.utc) + + await self.db.flush() + return branch + + async def mark_branch_status( + self, + branch_id: UUID, + status: str, + reason: str | None = None, + user_id: UUID | None = None, + ) -> SessionBranch: + """Update a branch's status.""" + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + raise ValueError(f"Branch {branch_id} not found") + + branch.status = status + branch.status_reason = reason + branch.status_changed_at = datetime.now(timezone.utc) + branch.status_changed_by = user_id + + await self.db.flush() + return branch + + async def revive_branch( + self, + branch_id: UUID, + evidence_from_branch_id: UUID, + evidence_description: str, + ) -> SessionBranch: + """Revive a dead-end branch with evidence from another branch.""" + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + raise ValueError(f"Branch {branch_id} not found") + + branch.status = "revived" + branch.status_changed_at = datetime.now(timezone.utc) + branch.evidence_from_branch_id = evidence_from_branch_id + branch.evidence_description = evidence_description + + revival_msg = { + "role": "system", + "content": f"[Branch Revived] New evidence from another branch: {evidence_description}", + } + msgs = list(branch.conversation_messages or []) + msgs.append(revival_msg) + branch.conversation_messages = msgs + + await self.db.flush() + return branch + + async def get_branch_tree(self, session_id: UUID) -> list[SessionBranch]: + """Get all branches for a session, ordered by branch_order.""" + result = await self.db.execute( + select(SessionBranch) + .where(SessionBranch.session_id == session_id) + .order_by(SessionBranch.branch_order) + ) + return list(result.scalars().all()) + + async def build_cross_branch_context(self, branch_id: UUID) -> str: + """Build cross-branch context from sibling summaries.""" + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + return "" + + siblings_result = await self.db.execute( + select(SessionBranch) + .where( + SessionBranch.session_id == branch.session_id, + SessionBranch.id != branch_id, + ) + .order_by(SessionBranch.branch_order) + ) + siblings = list(siblings_result.scalars().all()) + + if not siblings: + return "" + + priority = {"active": 0, "untried": 1, "revived": 2, "dead_end": 3, "solved": 4} + siblings.sort(key=lambda b: priority.get(b.status, 5)) + + parts = ["\n## Cross-Branch Context"] + for sib in siblings: + summary = sib.context_summary + if summary: + tried = ", ".join(summary.get("tried", [])) + concluded = summary.get("concluded", "No conclusion yet") + parts.append(f"- **{sib.label}** [{sib.status}]: Tried: {tried}. {concluded}") + else: + parts.append(f"- **{sib.label}** [{sib.status}]: No summary yet.") + + return "\n".join(parts) diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index 50aa1711..2f6f40ff 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -53,7 +53,10 @@ Your response MUST be a valid JSON object with one of these shapes: {"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request | open_script_builder", "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"]}\ +{"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"]} + +4. Diagnostic fork (explore multiple hypotheses in parallel): +{"type": "fork", "content": "Why we need to branch", "reasoning": "Internal why", "context_message": "Shown to engineer explaining the fork", "fork_reason": "Multiple possible root causes need independent investigation", "options": [{"label": "Branch name", "description": "What this branch will investigate"}], "confidence": 0.45}\ """ FLOWPILOT_SYSTEM_PROMPT = """\ @@ -83,6 +86,17 @@ Every response must have a "type" field: "question", "action", or "resolution_su - Never suggest restarting or rebooting as a first step — diagnose first - Be specific: "Check Event Viewer > System > source NTFS" not "check the logs" +## DIAGNOSTIC FORKING +When you detect MULTIPLE equally plausible root causes that require DIFFERENT investigation paths, use a "fork" response to let the engineer explore them as parallel branches. Use forks when: +- Two or more hypotheses have similar probability and investigating one doesn't help eliminate the other +- The engineer has tried the obvious path and results are ambiguous (could be DNS OR firewall OR auth) +- Symptoms point to multiple subsystems (e.g., "slow login" could be AD replication, DNS, or group policy) +Do NOT fork when: +- One hypothesis is clearly more likely — just investigate that first +- You can ask a single question that would eliminate most possibilities +- The session has fewer than 3 steps (gather more info first) +Fork options should be 2-4 independent investigation paths. Each option label should be a clear, short hypothesis name (e.g., "DNS Resolution Issue", "AD Replication Lag"). + {team_context} {matched_flow_context}\ @@ -121,7 +135,7 @@ def _parse_structured_output(raw_text: str) -> dict[str, Any]: if not isinstance(data, dict) or "type" not in data: raise ValueError("LLM response missing required 'type' field") - valid_types = {"question", "action", "resolution_suggestion"} + valid_types = {"question", "action", "resolution_suggestion", "fork"} if data["type"] not in valid_types: raise ValueError(f"Unknown response type: {data['type']}") @@ -319,6 +333,7 @@ async def start_session( parsed=parsed, input_tokens=input_tokens, output_tokens=output_tokens, + branch_id=session.active_branch_id if session.is_branching else None, ) db.add(step) @@ -421,11 +436,49 @@ async def process_response( parsed=parsed, input_tokens=input_tokens, output_tokens=output_tokens, + branch_id=session.active_branch_id if session.is_branching else None, ) db.add(step) await db.flush() + # Handle fork: create branches and enrich step content with branch IDs + if parsed["type"] == "fork": + from app.services.branch_manager import BranchManager + mgr = BranchManager(db) + + # Create root branch if this is the first fork in the session + if not session.is_branching: + root = await mgr.create_root_branch(session.id) + # Reassign the step to the root branch + step.branch_id = root.id + + fork_options = parsed.get("options", []) + fork_point, new_branches = await mgr.create_fork( + session_id=session.id, + parent_branch_id=session.active_branch_id, + trigger_step_id=step.id, + fork_reason=parsed.get("fork_reason", ""), + options=[{"label": o["label"], "description": o.get("description", "")} for o in fork_options], + ) + + # Enrich the step content with fork_point_id and branch IDs for frontend + enriched_content = dict(step.content or {}) + enriched_content["fork_point_id"] = str(fork_point.id) + enriched_content["fork_branches"] = [ + {"branch_id": str(b.id), "label": b.label} + for b in new_branches + ] + step.content = enriched_content + step.is_fork_point = True + step.fork_point_id = fork_point.id + + # Auto-switch to the first branch + first_branch = new_branches[0] + await mgr.switch_branch(session.id, first_branch.id) + + 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 @@ -640,6 +693,7 @@ async def pickup_session( briefing_step = AISessionStep( id=uuid.uuid4(), session_id=session.id, + branch_id=session.active_branch_id if session.is_branching else None, step_order=session.step_count, step_type="action", content={ @@ -714,6 +768,7 @@ async def pickup_session( parsed=parsed, input_tokens=input_tokens, output_tokens=output_tokens, + branch_id=session.active_branch_id if session.is_branching else None, ) db.add(next_step) @@ -926,6 +981,7 @@ async def generate_status_update( step = AISessionStep( id=uuid.uuid4(), session_id=session.id, + branch_id=session.active_branch_id if session.is_branching else None, step_order=session.step_count, step_type="status_update", content={ @@ -1207,6 +1263,7 @@ def _create_step_from_parsed( parsed: dict[str, Any], input_tokens: int, output_tokens: int, + branch_id: UUID | None = None, ) -> AISessionStep: """Create an AISessionStep from parsed LLM output.""" step_type = parsed["type"] @@ -1233,6 +1290,11 @@ def _create_step_from_parsed( content["follow_up_recommendations"] = parsed.get("follow_up_recommendations", []) content["allow_free_text"] = False content["allow_skip"] = False + elif parsed["type"] == "fork": + content["fork_reason"] = parsed.get("fork_reason", "") + content["fork_options"] = parsed.get("options", []) + content["allow_free_text"] = False + content["allow_skip"] = False # Extract options for question type options = None @@ -1244,6 +1306,7 @@ def _create_step_from_parsed( return AISessionStep( id=uuid.uuid4(), session_id=session_id, + branch_id=branch_id, step_order=step_order, step_type=step_type if parsed["type"] != "resolution_suggestion" else "action", content=content, diff --git a/backend/app/services/handoff_manager.py b/backend/app/services/handoff_manager.py new file mode 100644 index 00000000..8751e8b4 --- /dev/null +++ b/backend/app/services/handoff_manager.py @@ -0,0 +1,289 @@ +"""Handoff management — unified park/escalate with dual-write backward compat. + +Creates handoff snapshots, AI assessments (for escalations), claim workflow, +and queue queries. Dual-writes to ai_sessions.escalation_package for +backward compatibility with the existing escalation queue. +""" +import logging +from datetime import datetime, timezone +from typing import 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.session_branch import SessionBranch +from app.models.session_handoff import SessionHandoff + +logger = logging.getLogger(__name__) + + +class HandoffManager: + """Unified park/escalate handoff management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_handoff( + self, + session_id: UUID, + intent: str, + engineer_notes: str | None, + user_id: UUID, + priority: str = "normal", + ) -> SessionHandoff: + """Create a handoff (park or escalate). + + Generates snapshot, updates session status, dual-writes to + escalation_package for backward compat. + """ + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + # Generate snapshot + snapshot = await self._generate_snapshot(session) + + # Generate AI assessment for escalations + ai_assessment = None + ai_assessment_data = None + if intent == "escalate": + ai_assessment, ai_assessment_data = await self._generate_ai_assessment(session) + + handoff = SessionHandoff( + session_id=session_id, + handed_off_by=user_id, + intent=intent, + source_branch_id=session.active_branch_id, + snapshot=snapshot, + ai_assessment=ai_assessment, + ai_assessment_data=ai_assessment_data, + engineer_notes=engineer_notes, + priority=priority, + ) + self.db.add(handoff) + + # Update session status + if intent == "park": + session.status = "paused" + elif intent == "escalate": + session.status = "escalated" + + session.handoff_count = (session.handoff_count or 0) + 1 + + # Dual-write for backward compat + session.escalation_package = { + "snapshot": snapshot, + "intent": intent, + "engineer_notes": engineer_notes, + "handoff_id": str(handoff.id), + } + + await self.db.flush() + return handoff + + async def _generate_snapshot(self, session: AISession) -> dict[str, Any]: + """Generate a snapshot of the session state at handoff time.""" + snapshot: dict[str, Any] = { + "problem_summary": session.problem_summary, + "problem_domain": session.problem_domain, + "status": session.status, + "step_count": session.step_count, + "confidence_tier": session.confidence_tier, + } + + # Add branch map if branching is active + if session.is_branching: + branches_result = await self.db.execute( + select(SessionBranch) + .where(SessionBranch.session_id == session.id) + .order_by(SessionBranch.branch_order) + ) + branches = list(branches_result.scalars().all()) + + branch_map = [] + for b in branches: + branch_map.append({ + "id": str(b.id), + "label": b.label, + "status": b.status, + "status_reason": b.status_reason, + "parent_branch_id": str(b.parent_branch_id) if b.parent_branch_id else None, + }) + snapshot["branch_map"] = branch_map + snapshot["active_branch_id"] = str(session.active_branch_id) if session.active_branch_id else None + + return snapshot + + async def claim_session( + self, + handoff_id: UUID, + claiming_user_id: UUID, + ) -> SessionHandoff: + """Claim a handed-off session.""" + result = await self.db.execute( + select(SessionHandoff).where(SessionHandoff.id == handoff_id) + ) + handoff = result.scalar_one_or_none() + if not handoff: + raise ValueError(f"Handoff {handoff_id} not found") + + handoff.claimed_by = claiming_user_id + handoff.claimed_at = datetime.now(timezone.utc) + + # Reactivate session + session_result = await self.db.execute( + select(AISession).where(AISession.id == handoff.session_id) + ) + session = session_result.scalar_one() + session.status = "active" + + # Dual-write + session.escalated_to_id = claiming_user_id + + await self.db.flush() + return handoff + + async def _generate_ai_assessment( + self, session: AISession + ) -> tuple[str | None, dict[str, Any] | None]: + """Generate AI diagnostic assessment for escalation handoffs.""" + try: + from app.services.assistant_chat_service import _call_ai + + context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}" + msgs = session.conversation_messages or [] + # Include last 10 messages for context + recent = "\n".join( + f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}" + for m in msgs[-10:] + ) + + assessment_text, _, _ = await _call_ai( + system_base="You are a diagnostic assessment generator for MSP escalations.", + rag_context="", + history=[], + new_message=( + f"Generate a brief diagnostic assessment for this escalation.\n" + f"{context}\n\nRecent conversation:\n{recent}\n\n" + f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)" + ), + max_tokens=500, + ) + + assessment_data = { + "likely_cause": "See assessment text", + "suggested_steps": [], + "confidence": "medium", + } + + return assessment_text, assessment_data + except Exception: + logger.exception("Failed to generate AI assessment") + return None, None + + async def generate_briefing( + self, handoff_id: UUID, claiming_user_id: UUID + ) -> str: + """Generate a natural-language briefing for the engineer claiming the session.""" + result = await self.db.execute( + select(SessionHandoff).where(SessionHandoff.id == handoff_id) + ) + handoff = result.scalar_one_or_none() + if not handoff: + raise ValueError(f"Handoff {handoff_id} not found") + + session_result = await self.db.execute( + select(AISession).where(AISession.id == handoff.session_id) + ) + session = session_result.scalar_one() + + from app.services.assistant_chat_service import _call_ai + + snapshot_text = str(handoff.snapshot)[:2000] + briefing, _, _ = await _call_ai( + system_base="You are a handoff briefing generator for MSP teams.", + rag_context="", + history=[], + new_message=( + f"Generate a concise briefing for an engineer picking up this session.\n" + f"Problem: {session.problem_summary}\n" + f"Intent: {handoff.intent}\n" + f"Engineer notes: {handoff.engineer_notes or 'None'}\n" + f"Snapshot: {snapshot_text}\n" + f"AI Assessment: {handoff.ai_assessment or 'None'}" + ), + max_tokens=500, + ) + return briefing + + async def push_to_psa(self, handoff_id: UUID) -> SessionHandoff: + """Push handoff notes to PSA via existing psa_documentation_service.""" + result = await self.db.execute( + select(SessionHandoff).where(SessionHandoff.id == handoff_id) + ) + handoff = result.scalar_one_or_none() + if not handoff: + raise ValueError(f"Handoff {handoff_id} not found") + + try: + from app.services.psa_documentation_service import push_session_notes + session_result = await self.db.execute( + select(AISession).where(AISession.id == handoff.session_id) + ) + session = session_result.scalar_one() + if session.psa_ticket_id and session.psa_connection_id: + note_id = await push_session_notes( + session=session, + notes_content=handoff.ai_assessment or str(handoff.snapshot), + db=self.db, + ) + handoff.psa_note_pushed = True + handoff.psa_note_id = note_id + except Exception: + logger.exception(f"Failed to push handoff {handoff_id} to PSA") + + await self.db.flush() + return handoff + + async def get_queue( + self, + team_id: UUID | None = None, + account_id: UUID | None = None, + ) -> list[dict[str, Any]]: + """Get team queue of parked + escalated sessions.""" + query = ( + select(SessionHandoff, AISession) + .join(AISession, SessionHandoff.session_id == AISession.id) + .where(SessionHandoff.claimed_by.is_(None)) + .order_by(SessionHandoff.created_at.desc()) + ) + + if team_id: + query = query.where(AISession.team_id == team_id) + elif account_id: + query = query.where(AISession.account_id == account_id) + + result = await self.db.execute(query) + rows = result.all() + + queue_items = [] + for handoff, session in rows: + queue_items.append({ + "handoff_id": handoff.id, + "session_id": session.id, + "intent": handoff.intent, + "problem_summary": session.problem_summary, + "problem_domain": session.problem_domain, + "priority": handoff.priority, + "engineer_notes": handoff.engineer_notes, + "created_at": handoff.created_at, + "claimed_by": handoff.claimed_by, + "claimed_at": handoff.claimed_at, + }) + + return queue_items diff --git a/backend/app/services/resolution_output_generator.py b/backend/app/services/resolution_output_generator.py new file mode 100644 index 00000000..4d8f3e3d --- /dev/null +++ b/backend/app/services/resolution_output_generator.py @@ -0,0 +1,118 @@ +"""Resolution output generator — three deliverables on session resolve.""" +import logging +from typing import 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.session_resolution_output import SessionResolutionOutput +from app.services.assistant_chat_service import _call_ai + +logger = logging.getLogger(__name__) + +RESOLUTION_MODEL = "claude-sonnet-4-6" + + +class ResolutionOutputGenerator: + def __init__(self, db: AsyncSession): + self.db = db + + async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]: + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + context = self._build_session_context(session) + + outputs = [] + for output_type, prompt in [ + ("psa_ticket_notes", self._psa_notes_prompt(context)), + ("knowledge_base", self._kb_article_prompt(context)), + ("client_summary", self._client_summary_prompt(context)), + ]: + content, _, _ = await _call_ai( + system_base="You are a technical documentation assistant for MSP teams.", + rag_context="", + history=[], + new_message=prompt, + max_tokens=2048, + ) + + output = SessionResolutionOutput( + session_id=session_id, + output_type=output_type, + generated_content=content, + status="draft", + generated_by_model=RESOLUTION_MODEL, + ) + self.db.add(output) + outputs.append(output) + + await self.db.flush() + return outputs + + async def edit_output(self, output_id: UUID, edited_content: str) -> SessionResolutionOutput: + result = await self.db.execute( + select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id) + ) + output = result.scalar_one_or_none() + if not output: + raise ValueError(f"Output {output_id} not found") + output.edited_content = edited_content + await self.db.flush() + return output + + async def push_output(self, output_id: UUID, destination: str) -> SessionResolutionOutput: + result = await self.db.execute( + select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id) + ) + output = result.scalar_one_or_none() + if not output: + raise ValueError(f"Output {output_id} not found") + + from datetime import datetime, timezone + output.status = "pushed" + output.pushed_to = destination + output.pushed_at = datetime.now(timezone.utc) + await self.db.flush() + return output + + def _build_session_context(self, session: AISession) -> str: + parts = [ + f"Problem: {session.problem_summary or 'Unknown'}", + f"Domain: {session.problem_domain or 'Unknown'}", + f"Resolution: {session.resolution_summary or 'Not specified'}", + f"Steps taken: {session.step_count}", + ] + msgs = session.conversation_messages or [] + if msgs: + parts.append("\nConversation highlights:") + for msg in msgs[-10:]: + role = msg.get("role", "unknown") + content = msg.get("content", "")[:200] + parts.append(f" [{role}]: {content}") + return "\n".join(parts) + + def _psa_notes_prompt(self, context: str) -> str: + return ( + f"Generate professional PSA ticket notes for this resolved troubleshooting session.\n" + f"Format as structured markdown with: Problem, Diagnostic Steps, Resolution, Recommendations.\n\n{context}" + ) + + def _kb_article_prompt(self, context: str) -> str: + return ( + f"Generate a knowledge base article draft from this resolved session.\n" + f"Include: Symptoms, Root Cause, Resolution Steps, Things to Rule Out First.\n\n{context}" + ) + + def _client_summary_prompt(self, context: str) -> str: + return ( + f"Generate a non-technical summary for the end user/client.\n" + f"Explain what was wrong and what was done to fix it in plain language.\n" + f"No jargon. 2-3 paragraphs max.\n\n{context}" + ) diff --git a/backend/app/services/unified_chat_service.py b/backend/app/services/unified_chat_service.py index e04054fc..494eb01f 100644 --- a/backend/app/services/unified_chat_service.py +++ b/backend/app/services/unified_chat_service.py @@ -4,7 +4,9 @@ Replaces assistant_chat_service for new chat sessions. Messages are stored in ai_sessions.conversation_messages JSONB. Reuses the same AI calling infrastructure and system prompt from assistant_chat_service. """ +import json import logging +import re from typing import Any from uuid import UUID @@ -22,6 +24,129 @@ from app.services.rag_service import search as rag_search, build_rag_context, ex logger = logging.getLogger(__name__) +def _parse_fork_marker(ai_content: str) -> tuple[str, dict[str, Any] | None]: + """Extract [FORK]...[/FORK] JSON from AI response. + + Returns (cleaned_content, fork_data_or_None). + The fork marker is stripped from the display text. + """ + match = re.search(r'\[FORK\]\s*([\s\S]*?)\s*\[/FORK\]', ai_content) + if not match: + return ai_content, None + + try: + raw = match.group(1).strip() + # Strip markdown fences if AI wrapped it + if raw.startswith("```"): + raw = re.sub(r'^```(?:json)?\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw) + fork_data = json.loads(raw) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse [FORK] marker: %s", e) + return ai_content, None + + # Validate structure + if not isinstance(fork_data, dict) or "options" not in fork_data: + logger.warning("Invalid [FORK] data — missing 'options'") + return ai_content, None + + options = fork_data["options"] + if not isinstance(options, list) or len(options) < 2: + logger.warning("Invalid [FORK] data — need at least 2 options") + return ai_content, None + + # Strip the marker from display text + cleaned = ai_content[:match.start()] + ai_content[match.end():] + cleaned = cleaned.strip() + + return cleaned, fork_data + + +def _parse_actions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]] | None]: + """Extract [ACTIONS]...[/ACTIONS] JSON from AI response. + + Returns (cleaned_content, actions_list_or_None). + The actions marker is stripped from the display text. + """ + match = re.search(r'\[ACTIONS\]\s*([\s\S]*?)\s*\[/ACTIONS\]', ai_content) + if not match: + return ai_content, None + + try: + raw = match.group(1).strip() + if raw.startswith("```"): + raw = re.sub(r'^```(?:json)?\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw) + actions = json.loads(raw) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse [ACTIONS] marker: %s", e) + return ai_content, None + + if not isinstance(actions, list) or len(actions) == 0: + logger.warning("Invalid [ACTIONS] data — need at least 1 action") + return ai_content, None + + # Validate each action has at minimum a label + valid_actions = [] + for a in actions: + if isinstance(a, dict) and a.get("label"): + valid_actions.append({ + "label": a["label"], + "command": a.get("command"), + "description": a.get("description", ""), + }) + + if not valid_actions: + return ai_content, None + + cleaned = ai_content[:match.start()] + ai_content[match.end():] + cleaned = cleaned.strip() + + return cleaned, valid_actions + + +def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]] | None]: + """Extract [QUESTIONS]...[/QUESTIONS] JSON from AI response. + + Returns (cleaned_content, questions_list_or_None). + The questions marker is stripped from the display text. + """ + match = re.search(r'\[QUESTIONS\]\s*([\s\S]*?)\s*\[/QUESTIONS\]', ai_content) + if not match: + return ai_content, None + + try: + raw = match.group(1).strip() + if raw.startswith("```"): + raw = re.sub(r'^```(?:json)?\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw) + questions = json.loads(raw) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse [QUESTIONS] marker: %s", e) + return ai_content, None + + if not isinstance(questions, list) or len(questions) == 0: + logger.warning("Invalid [QUESTIONS] data — need at least 1 question") + return ai_content, None + + # Validate each question has at minimum a text field + valid_questions = [] + for q in questions: + if isinstance(q, dict) and q.get("text"): + valid_questions.append({ + "text": q["text"], + "context": q.get("context", ""), + }) + + if not valid_questions: + return ai_content, None + + cleaned = ai_content[:match.start()] + ai_content[match.end():] + cleaned = cleaned.strip() + + return cleaned, valid_questions + + async def create_chat_session( user_id: UUID, account_id: UUID, @@ -58,14 +183,14 @@ async def send_chat_message( message: str, db: AsyncSession, images: list[dict[str, Any]] | None = None, -) -> tuple[str, list[dict[str, Any]], AISession]: +) -> tuple[str, list[dict[str, Any]], AISession, dict[str, Any] | None, list[dict[str, Any]] | None, list[dict[str, Any]] | None]: """Send a message in a chat session and get AI response. Args: images: Optional list of {"media_type": str, "data": str (base64)} for vision content attached to this message. - Returns (ai_content, suggested_flows, session). + Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data). """ result = await db.execute( select(AISession).where( @@ -81,6 +206,91 @@ async def send_chat_message( if session.status not in ("active", "paused"): raise ValueError(f"Cannot send messages to a {session.status} session") + # If branching is active, route to branch message handler + if session.is_branching and session.active_branch_id: + from app.services.branch_manager import BranchManager + from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder + from app.models.session_branch import SessionBranch + + branch_result = await db.execute( + select(SessionBranch).where(SessionBranch.id == session.active_branch_id) + ) + branch = branch_result.scalar_one_or_none() + if branch: + manager = BranchManager(db) + sibling_ctx = await manager.build_cross_branch_context(branch.id) + + builder = BranchAwarePromptBuilder() + session_context = f"Problem: {session.problem_summary or 'Unknown'}. Domain: {session.problem_domain or 'Unknown'}." + prompt_args = builder.build( + branch_messages=branch.conversation_messages, + sibling_summaries=sibling_ctx, + session_context=session_context, + attachments=[], + new_message=message, + revival_context=branch.evidence_description if branch.status == "revived" else None, + ) + + # Override images from prompt_args with actual images if provided + if images: + prompt_args["images"] = images + ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args) + + # Update branch conversation + msgs = list(branch.conversation_messages or []) + msgs.append({"role": "user", "content": message}) + msgs.append({"role": "assistant", "content": ai_content}) + branch.conversation_messages = msgs + + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + session.step_count += 2 + + if session.status == "paused": + session.status = "active" + + # Check for fork, actions, and questions markers in branch response too + branch_display, branch_fork_data = _parse_fork_marker(ai_content) + branch_display, branch_actions_data = _parse_actions_marker(branch_display) + branch_display, branch_questions_data = _parse_questions_marker(branch_display) + if branch_display != ai_content: + # Store stripped content in branch history + msgs[-1] = {"role": "assistant", "content": branch_display} + branch.conversation_messages = msgs + + branch_fork_metadata = None + if branch_fork_data: + try: + fork_point, new_branches = await manager.create_fork( + session_id=session.id, + parent_branch_id=branch.id, + trigger_step_id=None, + fork_reason=branch_fork_data.get("fork_reason", ""), + options=[ + {"label": o["label"], "description": o.get("description", "")} + for o in branch_fork_data["options"] + ], + ) + first_branch = new_branches[0] + await manager.switch_branch(session.id, first_branch.id) + branch_fork_metadata = { + "fork_point_id": str(fork_point.id), + "fork_reason": branch_fork_data.get("fork_reason", ""), + "branches": [ + {"branch_id": str(b.id), "label": b.label} + for b in new_branches + ], + "active_branch_id": str(first_branch.id), + } + await db.flush() + except Exception: + logger.exception("Failed to create fork within branch for session %s", session.id) + + suggested_flows = extract_suggested_flows( + await rag_search(query=message, account_id=account_id, db=db, limit=8) + ) + return branch_display, suggested_flows, session, branch_fork_metadata, branch_actions_data, branch_questions_data + # Auto-title from first message if still default if session.step_count == 0 and message.strip(): session.title = _auto_title(message) @@ -113,10 +323,27 @@ async def send_chat_message( images=images, ) - # Append messages to conversation_messages + # Check for fork marker in AI response + display_content, fork_data = _parse_fork_marker(ai_content) + + # Check for actions marker in AI response + display_content, actions_data = _parse_actions_marker(display_content) + + # Check for questions marker in AI response + display_content, questions_data = _parse_questions_marker(display_content) + + logger.info( + "Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d", + bool(actions_data), bool(questions_data), bool(fork_data), + len(ai_content), len(display_content), + ) + + # Store DISPLAY content (markers stripped) in conversation_messages. + # The format reminder in the user message + system prompt final reminder + # are sufficient to keep the AI emitting markers on subsequent turns. msgs = list(session.conversation_messages or []) msgs.append({"role": "user", "content": message}) - msgs.append({"role": "assistant", "content": ai_content}) + msgs.append({"role": "assistant", "content": display_content}) session.conversation_messages = msgs session.step_count += 2 # message count for display session.total_input_tokens += input_tokens @@ -126,6 +353,46 @@ async def send_chat_message( if session.status == "paused": session.status = "active" + # If fork was detected, create branches + fork_metadata = None + if fork_data: + try: + from app.services.branch_manager import BranchManager + mgr = BranchManager(db) + + # Create root branch if this is the first fork + if not session.is_branching: + await mgr.create_root_branch(session.id) + + fork_point, new_branches = await mgr.create_fork( + session_id=session.id, + parent_branch_id=session.active_branch_id, + trigger_step_id=None, + fork_reason=fork_data.get("fork_reason", ""), + options=[ + {"label": o["label"], "description": o.get("description", "")} + for o in fork_data["options"] + ], + ) + + # Don't auto-switch — conversation continues on current branch. + # Branches appear in sidebar. User switches when ready. + fork_metadata = { + "fork_point_id": str(fork_point.id), + "fork_reason": fork_data.get("fork_reason", ""), + "branches": [ + {"branch_id": str(b.id), "label": b.label} + for b in new_branches + ], + "active_branch_id": str(session.active_branch_id) if session.active_branch_id else None, + } + + await db.flush() + logger.info("Created fork with %d branches for session %s", len(new_branches), session_id) + except Exception: + logger.exception("Failed to create fork for session %s", session_id) + # Fork failed but chat message still sent — don't break the response + suggested_flows = extract_suggested_flows(rag_results) - return ai_content, suggested_flows, session + return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data diff --git a/backend/tests/test_branch_aware_prompt_builder.py b/backend/tests/test_branch_aware_prompt_builder.py new file mode 100644 index 00000000..6b0541d7 --- /dev/null +++ b/backend/tests/test_branch_aware_prompt_builder.py @@ -0,0 +1,85 @@ +"""Unit tests for BranchAwarePromptBuilder — pure function, no DB needed.""" +import pytest +from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder + + +def test_build_basic(): + """Basic build with no cross-branch context.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[ + {"role": "user", "content": "DNS not resolving"}, + {"role": "assistant", "content": "Let's check DNS config"}, + ], + sibling_summaries="", + session_context="Problem: DNS resolution failure. Domain: networking.", + attachments=[], + new_message="I ran nslookup and got timeout", + ) + assert "system_base" in result + assert "rag_context" in result + assert "history" in result + assert "new_message" in result + assert "images" in result + assert result["new_message"] == "I ran nslookup and got timeout" + assert len(result["history"]) == 2 + + +def test_build_with_cross_branch_context(): + """Cross-branch summaries go into rag_context, not system_base.""" + builder = BranchAwarePromptBuilder() + sibling_ctx = "\n## Cross-Branch Context\n- **Network** [dead_end]: Network was fine." + result = builder.build( + branch_messages=[], + sibling_summaries=sibling_ctx, + session_context="Problem: test", + attachments=[], + new_message="test message", + ) + assert "Cross-Branch Context" in result["rag_context"] + assert "Cross-Branch Context" not in result["system_base"] + + +def test_build_with_images(): + """Image attachments are passed through.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[], + sibling_summaries="", + session_context="Problem: test", + attachments=[{"media_type": "image/png", "data": "base64data"}], + new_message="check this screenshot", + ) + assert len(result["images"]) == 1 + assert result["images"][0]["media_type"] == "image/png" + + +def test_build_with_revival_context(): + """Revival context is prepended to rag_context.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[], + sibling_summaries="", + session_context="Problem: test", + attachments=[], + new_message="test", + revival_context="New evidence: the error appears when VPN is active", + ) + assert "New evidence" in result["rag_context"] + + +def test_history_filters_to_user_assistant(): + """Only user and assistant messages appear in history.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + {"role": "system", "content": "should be excluded"}, + ], + sibling_summaries="", + session_context="Problem: test", + attachments=[], + new_message="third", + ) + assert len(result["history"]) == 2 diff --git a/backend/tests/test_branch_manager.py b/backend/tests/test_branch_manager.py new file mode 100644 index 00000000..923ee6b1 --- /dev/null +++ b/backend/tests/test_branch_manager.py @@ -0,0 +1,218 @@ +"""Integration tests for BranchManager service.""" +import uuid +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_branch import SessionBranch +from app.models.fork_point import ForkPoint +from app.models.ai_session_step import AISessionStep + + +@pytest.mark.asyncio +async def test_create_root_branch(client: AsyncClient, test_user, auth_headers, test_db): + """Creating a root branch sets is_branching=True and copies conversation_messages.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[ + {"role": "user", "content": "test message"}, + {"role": "assistant", "content": "test response"}, + ], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + assert root is not None + assert root.parent_branch_id is None + assert root.label == "Root" + assert root.status == "active" + assert root.branch_order == 1 + assert len(root.conversation_messages) == 2 + + await test_db.refresh(session) + assert session.is_branching is True + assert session.active_branch_id == root.id + + +@pytest.mark.asyncio +async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db): + """Creating a fork produces a ForkPoint + N branches.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + step = AISessionStep( + session_id=session.id, + step_order=0, + step_type="question", + content={"text": "What's the issue?"}, + confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.flush() + + fork_point, branches = await manager.create_fork( + session_id=session.id, + parent_branch_id=root.id, + trigger_step_id=step.id, + fork_reason="Two possible causes identified", + options=[ + {"label": "Network connectivity", "description": "Check network stack"}, + {"label": "DNS resolution", "description": "Check DNS config"}, + ], + ) + + assert fork_point is not None + assert len(branches) == 2 + assert branches[0].label == "Network connectivity" + assert branches[0].status == "untried" + assert branches[0].parent_branch_id == root.id + assert branches[1].label == "DNS resolution" + assert branches[1].branch_order == 2 + + await test_db.refresh(step) + assert step.is_fork_point is True + assert step.fork_point_id == fork_point.id + + +@pytest.mark.asyncio +async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_db): + """Switching branches updates active_branch_id.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.flush() + + _, branches = await manager.create_fork( + session_id=session.id, + parent_branch_id=root.id, + trigger_step_id=step.id, + fork_reason="test fork", + options=[ + {"label": "Option A", "description": "desc A"}, + {"label": "Option B", "description": "desc B"}, + ], + ) + + branch_b = branches[1] + result = await manager.switch_branch(session.id, branch_b.id) + + assert result.id == branch_b.id + await test_db.refresh(session) + assert session.active_branch_id == branch_b.id + + +@pytest.mark.asyncio +async def test_mark_branch_dead_end(client: AsyncClient, test_user, auth_headers, test_db): + """Marking a branch as dead_end updates status.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + updated = await manager.mark_branch_status( + branch_id=root.id, + status="dead_end", + reason="Network was fine, not the cause", + user_id=test_user["user_data"]["id"], + ) + + assert updated.status == "dead_end" + assert updated.status_reason == "Network was fine, not the cause" + assert updated.status_changed_at is not None + + +@pytest.mark.asyncio +async def test_get_branch_tree(client: AsyncClient, test_user, auth_headers, test_db): + """get_branch_tree returns the full tree structure.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.flush() + + await manager.create_fork( + session_id=session.id, + parent_branch_id=root.id, + trigger_step_id=step.id, + fork_reason="test", + options=[ + {"label": "A", "description": "a"}, + {"label": "B", "description": "b"}, + ], + ) + + tree = await manager.get_branch_tree(session.id) + assert len(tree) == 3 # Root + 2 fork branches diff --git a/backend/tests/test_handoff_manager.py b/backend/tests/test_handoff_manager.py new file mode 100644 index 00000000..6e1e530e --- /dev/null +++ b/backend/tests/test_handoff_manager.py @@ -0,0 +1,115 @@ +"""Integration tests for HandoffManager service.""" +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession + + +@pytest.mark.asyncio +async def test_create_park_handoff(client: AsyncClient, test_user, auth_headers, test_db): + """Parking a session creates a handoff with snapshot.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help me"}], + ) + test_db.add(session) + await test_db.flush() + + from app.services.handoff_manager import HandoffManager + manager = HandoffManager(test_db) + + handoff = await manager.create_handoff( + session_id=session.id, + intent="park", + engineer_notes="Waiting for client to provide logs", + user_id=test_user["user_data"]["id"], + ) + + assert handoff is not None + assert handoff.intent == "park" + assert handoff.engineer_notes == "Waiting for client to provide logs" + assert handoff.snapshot is not None + + # Session should be paused + await test_db.refresh(session) + assert session.status == "paused" + assert session.handoff_count == 1 + + +@pytest.mark.asyncio +async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_headers, test_db): + """Escalating creates handoff + dual-writes to escalation_package.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.handoff_manager import HandoffManager + manager = HandoffManager(test_db) + + handoff = await manager.create_handoff( + session_id=session.id, + intent="escalate", + engineer_notes="Need senior help", + user_id=test_user["user_data"]["id"], + ) + + assert handoff.intent == "escalate" + + # Dual-write check + await test_db.refresh(session) + assert session.status == "escalated" + assert session.escalation_package is not None + assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package + + +@pytest.mark.asyncio +async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_headers, test_db): + """Claiming a handoff sets claimed_by and reactivates session.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.handoff_manager import HandoffManager + manager = HandoffManager(test_db) + + handoff = await manager.create_handoff( + session_id=session.id, + intent="escalate", + engineer_notes="Need help", + user_id=test_user["user_data"]["id"], + ) + + claimed = await manager.claim_session( + handoff_id=handoff.id, + claiming_user_id=test_admin["user_data"]["id"], + ) + + assert claimed.claimed_by == test_admin["user_data"]["id"] + assert claimed.claimed_at is not None + + await test_db.refresh(session) + assert session.status == "active" diff --git a/backend/tests/test_resolution_outputs.py b/backend/tests/test_resolution_outputs.py new file mode 100644 index 00000000..a852ebca --- /dev/null +++ b/backend/tests/test_resolution_outputs.py @@ -0,0 +1,77 @@ +"""Integration tests for ResolutionOutputGenerator.""" +import pytest +from unittest.mock import AsyncMock, patch +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput + + +@pytest.mark.asyncio +@patch("app.services.resolution_output_generator._call_ai") +async def test_generate_all_creates_three_outputs( + mock_call_ai, client: AsyncClient, test_user, auth_headers, test_db +): + """generate_all creates PSA notes, KB article, and client summary.""" + mock_call_ai.return_value = ("Generated content here", 100, 50) + + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[ + {"role": "user", "content": "DNS not working"}, + {"role": "assistant", "content": "Fixed by flushing DNS cache"}, + ], + resolution_summary="Flushed DNS cache", + ) + test_db.add(session) + await test_db.flush() + + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(test_db) + outputs = await gen.generate_all(session.id) + + assert len(outputs) == 3 + types = {o.output_type for o in outputs} + assert types == {"psa_ticket_notes", "knowledge_base", "client_summary"} + assert all(o.status == "draft" for o in outputs) + assert mock_call_ai.call_count == 3 + + +@pytest.mark.asyncio +async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db): + """Editing an output stores edited_content.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[], + resolution_summary="Fixed it", + ) + test_db.add(session) + await test_db.flush() + + output = SessionResolutionOutput( + session_id=session.id, + output_type="psa_ticket_notes", + generated_content="Original notes", + status="draft", + generated_by_model="claude-sonnet-4-6", + ) + test_db.add(output) + await test_db.flush() + + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(test_db) + edited = await gen.edit_output(output.id, "My edited notes") + + assert edited.edited_content == "My edited notes" diff --git a/backend/tests/test_session_branches_api.py b/backend/tests/test_session_branches_api.py new file mode 100644 index 00000000..74920f05 --- /dev/null +++ b/backend/tests/test_session_branches_api.py @@ -0,0 +1,118 @@ +"""API endpoint tests for session branches.""" +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.ai_session_step import AISessionStep + + +@pytest.mark.asyncio +async def test_list_branches_empty(client: AsyncClient, test_user, auth_headers, test_db): + """GET /ai-sessions/{id}/branches returns empty for non-branching session.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + resp = await client.get( + f"/api/v1/ai-sessions/{session.id}/branches", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["branches"] == [] + assert data["active_branch_id"] is None + + +@pytest.mark.asyncio +async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/branches/fork creates branches.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.flush() + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.commit() + + resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/branches/fork", + headers=auth_headers, + json={ + "fork_reason": "Two possible causes", + "options": [ + {"label": "Network issue", "description": "Check connectivity"}, + {"label": "DNS problem", "description": "Check DNS"}, + ], + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert len(data["options"]) == 2 + + +@pytest.mark.asyncio +async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/branches/{bid}/switch changes active branch.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.flush() + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.commit() + + # Create fork first + fork_resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/branches/fork", + headers=auth_headers, + json={ + "fork_reason": "test", + "options": [ + {"label": "A", "description": "a"}, + {"label": "B", "description": "b"}, + ], + }, + ) + fork_data = fork_resp.json() + branch_b_id = fork_data["options"][1]["branch_id"] + + # Switch to branch B + resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/branches/{branch_b_id}/switch", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["active_branch_id"] == branch_b_id diff --git a/backend/tests/test_session_handoffs_api.py b/backend/tests/test_session_handoffs_api.py new file mode 100644 index 00000000..26a47988 --- /dev/null +++ b/backend/tests/test_session_handoffs_api.py @@ -0,0 +1,60 @@ +"""API endpoint tests for session handoffs.""" +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession + + +@pytest.mark.asyncio +async def test_create_park_handoff_api(client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/handoff with intent=park.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/handoff", + headers=auth_headers, + json={"intent": "park", "engineer_notes": "Waiting for logs"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["intent"] == "park" + + +@pytest.mark.asyncio +async def test_get_queue(client: AsyncClient, test_user, auth_headers, test_db): + """GET /ai-sessions/queue returns unclaimed handoffs.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + # Create a handoff + await client.post( + f"/api/v1/ai-sessions/{session.id}/handoff", + headers=auth_headers, + json={"intent": "escalate", "engineer_notes": "Need help"}, + ) + + resp = await client.get("/api/v1/ai-sessions/queue", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 diff --git a/backend/tests/test_session_resolutions_api.py b/backend/tests/test_session_resolutions_api.py new file mode 100644 index 00000000..deac8c70 --- /dev/null +++ b/backend/tests/test_session_resolutions_api.py @@ -0,0 +1,62 @@ +"""API tests for resolution output endpoints.""" +import pytest +from unittest.mock import patch +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput + + +@pytest.mark.asyncio +async def test_get_outputs_empty(client: AsyncClient, test_user, auth_headers, test_db): + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="guided", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + resp = await client.get(f"/api/v1/ai-sessions/{session.id}/outputs", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["outputs"] == [] + + +@pytest.mark.asyncio +async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, test_db): + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[], + resolution_summary="Fixed", + ) + test_db.add(session) + await test_db.flush() + + output = SessionResolutionOutput( + session_id=session.id, + output_type="psa_ticket_notes", + generated_content="Original", + status="draft", + generated_by_model="claude-sonnet-4-6", + ) + test_db.add(output) + await test_db.commit() + + resp = await client.patch( + f"/api/v1/ai-sessions/{session.id}/outputs/{output.id}", + headers=auth_headers, + json={"edited_content": "My edited version"}, + ) + assert resp.status_code == 200 + assert resp.json()["edited_content"] == "My edited version" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2d982fd0..bd5a4206 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -57,7 +57,7 @@ services: - ./frontend:/app - /app/node_modules environment: - - VITE_API_URL=https://dev.resolutionflow.com/api + - VITE_API_URL=https://dev.resolutionflow.com/ depends_on: - backend labels: diff --git a/docs/superpowers/plans/2026-03-24-conversational-branching.md b/docs/superpowers/plans/2026-03-24-conversational-branching.md new file mode 100644 index 00000000..8f40987d --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-conversational-branching.md @@ -0,0 +1,5051 @@ +# Conversational Branching Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add branching troubleshooting to FlowPilot — engineers explore multiple diagnostic hypotheses as first-class branches with cross-branch AI context, unified park/escalate handoff, and three-output resolution packages. + +**Architecture:** Additive layer on top of existing FlowPilot. Four new DB tables + nullable columns on three existing tables. Four new backend services that call `_call_ai` from `assistant_chat_service.py` for all LLM interactions. Frontend adds Branch Map sidebar, Fork Cards, Handoff modal, and Resolution panel. All branching logic gated behind `session.is_branching` — removing the feature = drop tables + remove guards. + +**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 (async), PostgreSQL, Alembic migrations, React 19 + TypeScript, Tailwind CSS v4, Zustand, Axios, Lucide React icons. + +**Spec:** `docs/superpowers/specs/2026-03-24-conversational-branching-design.md` + +--- + +## File Structure + +### New Backend Files + +| File | Responsibility | +|------|---------------| +| `backend/app/models/session_branch.py` | `SessionBranch` SQLAlchemy model | +| `backend/app/models/fork_point.py` | `ForkPoint` SQLAlchemy model | +| `backend/app/models/session_handoff.py` | `SessionHandoff` SQLAlchemy model | +| `backend/app/models/session_resolution_output.py` | `SessionResolutionOutput` SQLAlchemy model | +| `backend/app/schemas/session_branch.py` | Pydantic schemas for branches + fork points | +| `backend/app/schemas/session_handoff.py` | Pydantic schemas for handoffs | +| `backend/app/schemas/session_resolution.py` | Pydantic schemas for resolution outputs | +| `backend/app/services/branch_manager.py` | Branch lifecycle: create root, fork, switch, mark status, revive, tree query, context summary | +| `backend/app/services/branch_aware_prompt_builder.py` | Pure function: assembles system prompt + messages + images with cross-branch context for `_call_ai` | +| `backend/app/services/handoff_manager.py` | Park/escalate with dual-write backward compat, snapshot, AI assessment, claim, PSA push | +| `backend/app/services/resolution_output_generator.py` | PSA notes, KB article, client summary — three LLM calls on resolve | +| `backend/app/api/endpoints/session_branches.py` | Branch CRUD + fork + switch + revive + branch message endpoints | +| `backend/app/api/endpoints/session_handoffs.py` | Handoff create + history + claim + queue endpoints | +| `backend/app/api/endpoints/session_resolutions.py` | Resolution output CRUD + push endpoints | +| `backend/alembic/versions/030_add_conversational_branching.py` | Single migration: 4 new tables + columns on 3 existing tables | +| `backend/tests/test_branch_manager.py` | Integration tests for BranchManager | +| `backend/tests/test_branch_aware_prompt_builder.py` | Unit tests for prompt builder (pure function, no DB) | +| `backend/tests/test_handoff_manager.py` | Integration tests for HandoffManager | +| `backend/tests/test_resolution_outputs.py` | Integration tests for ResolutionOutputGenerator | +| `backend/tests/test_session_branches_api.py` | API endpoint tests for branches | +| `backend/tests/test_session_handoffs_api.py` | API endpoint tests for handoffs | +| `backend/tests/test_session_resolutions_api.py` | API endpoint tests for resolutions | + +### New Frontend Files + +| File | Responsibility | +|------|---------------| +| `frontend/src/types/branching.ts` | TypeScript interfaces for branches, forks, handoffs, resolution outputs | +| `frontend/src/api/branches.ts` | Axios client for branch endpoints | +| `frontend/src/api/handoffs.ts` | Axios client for handoff endpoints | +| `frontend/src/api/resolutions.ts` | Axios client for resolution output endpoints | +| `frontend/src/hooks/useBranching.ts` | Branch state management hook | +| `frontend/src/hooks/useHandoff.ts` | Handoff flow state hook | +| `frontend/src/hooks/useResolutionOutputs.ts` | Resolution output state hook | +| `frontend/src/components/session/BranchMap.tsx` | Sidebar tree visualization with status badges | +| `frontend/src/components/session/BranchNode.tsx` | Individual node in branch map | +| `frontend/src/components/session/ForkCard.tsx` | In-chat fork decision point card | +| `frontend/src/components/session/BranchTransitionBar.tsx` | Context bar shown on branch switch | +| `frontend/src/components/session/BranchRevivalCard.tsx` | Evidence card for revival | +| `frontend/src/components/session/HandoffModal.tsx` | Unified park/escalate modal | +| `frontend/src/components/session/ResolutionOutputPanel.tsx` | Three-tab resolution view + edit + push | +| `frontend/src/pages/SessionQueuePage.tsx` | Team queue for parked/escalated sessions | + +### Modified Existing Files + +| File | Change | +|------|--------| +| `backend/app/models/ai_session.py` | Add 5 columns: `is_branching`, `active_branch_id`, `handoff_count`, `total_active_seconds`, `total_parked_seconds` | +| `backend/app/models/ai_session_step.py` | Add 3 columns: `branch_id`, `is_fork_point`, `fork_point_id`. Add `'fork'` to step_type CHECK constraint | +| `backend/app/models/file_upload.py` | Add 5 columns: `ai_description`, `extracted_content`, `content_summary`, `uploaded_on_branch_id`, `uploaded_at_step_id` | +| `backend/app/models/__init__.py` | Import 4 new models, add to `__all__` | +| `backend/alembic/env.py` | Import 4 new models for metadata tracking | +| `backend/app/api/router.py` | Register 3 new endpoint routers | +| `backend/app/schemas/ai_session.py` | Add `is_branching`, `active_branch_id` to `AISessionDetail` | +| `backend/app/services/unified_chat_service.py` | Add `if session.is_branching:` guard to route messages to branch | +| `backend/app/services/flowpilot_engine.py` | Set `branch_id` on `AISessionStep` creation when `is_branching=True` | +| `backend/app/api/endpoints/uploads.py` | Add async AI description generation background task on upload | +| `frontend/src/types/ai-session.ts` | Add `is_branching`, `active_branch_id` to `AISessionDetail` | +| `frontend/src/api/index.ts` | Export new API clients | +| `frontend/src/types/index.ts` | Export new types | +| `frontend/src/pages/FlowPilotSessionPage.tsx` | Integrate BranchMap sidebar, ForkCard rendering, branch-aware message routing | +| `frontend/src/router.tsx` | Add SessionQueuePage route | + +--- + +## Phase 1: Data Foundation + +### Task 1: Create SessionBranch Model + +**Files:** +- Create: `backend/app/models/session_branch.py` +- Test: `backend/tests/test_branch_manager.py` (started here, completed in Phase 2) + +- [ ] **Step 1: Write the SessionBranch model** + +```python +# backend/app/models/session_branch.py +"""Session branch model — represents a diagnostic hypothesis path within a FlowPilot session.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, CheckConstraint, Index +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.ai_session_step import AISessionStep + from app.models.user import User + + +class SessionBranch(Base): + """A diagnostic branch within a FlowPilot session. + + Each branch represents one hypothesis being explored. Branches form a tree + via parent_branch_id (NULL = root branch). Status tracks the outcome: + active, dead_end, solved, untried, revived. + """ + __tablename__ = "session_branches" + __table_args__ = ( + CheckConstraint( + "status IN ('active', 'dead_end', 'solved', 'untried', 'revived')", + name="ck_session_branches_status", + ), + CheckConstraint( + "branch_order > 0", + name="ck_session_branches_branch_order_positive", + ), + Index("ix_session_branches_session_id", "session_id"), + Index("ix_session_branches_parent_branch_id", "parent_branch_id"), + Index("ix_session_branches_session_status", "session_id", "status"), + Index("ix_session_branches_session_order", "session_id", "branch_order"), + ) + + 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, + ) + parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="CASCADE"), + nullable=True, + ) + fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("ai_session_steps.id", ondelete="SET NULL"), + nullable=True, + ) + branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + label: Mapped[str] = mapped_column(String(200), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + status_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status_changed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + status_changed_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB, nullable=False, default=list, + comment="LLM message history scoped to this branch", + ) + context_summary: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, nullable=True, + comment="{tried: [], concluded: str, artifacts: []}", + ) + evidence_from_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="SET NULL"), + nullable=True, + ) + evidence_description: Mapped[Optional[str]] = mapped_column(Text, nullable=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), + ) + + # Relationships + session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id]) + parent_branch: Mapped[Optional["SessionBranch"]] = relationship( + "SessionBranch", remote_side="SessionBranch.id", + foreign_keys=[parent_branch_id], + ) + fork_point_step: Mapped[Optional["AISessionStep"]] = relationship( + "AISessionStep", foreign_keys=[fork_point_step_id] + ) + status_changed_by_user: Mapped[Optional["User"]] = relationship( + "User", foreign_keys=[status_changed_by] + ) + evidence_source: Mapped[Optional["SessionBranch"]] = relationship( + "SessionBranch", remote_side="SessionBranch.id", + foreign_keys=[evidence_from_branch_id], + ) +``` + +- [ ] **Step 2: Verify file was created correctly** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.models.session_branch import SessionBranch; print('OK:', SessionBranch.__tablename__)"` +Expected: `OK: session_branches` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/models/session_branch.py +git commit -m "feat: add SessionBranch model for conversational branching" +``` + +### Task 2: Create ForkPoint Model + +**Files:** +- Create: `backend/app/models/fork_point.py` + +- [ ] **Step 1: Write the ForkPoint model** + +```python +# backend/app/models/fork_point.py +"""Fork point model — captures decision points where a session branches.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import Text, DateTime, ForeignKey +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.session_branch import SessionBranch + from app.models.ai_session_step import AISessionStep + + +class ForkPoint(Base): + """A decision point where a session forks into multiple branches. + + Stores the fork reason and the options presented (each becoming a branch). + options JSONB: [{label, description, branch_id, status}] + """ + __tablename__ = "fork_points" + + 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, + ) + parent_branch_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="CASCADE"), + nullable=False, + ) + trigger_step_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("ai_session_steps.id", ondelete="SET NULL"), + nullable=True, + ) + fork_reason: Mapped[str] = mapped_column(Text, nullable=False) + options: Mapped[list[dict[str, Any]]] = mapped_column( + JSONB, nullable=False, default=list, + comment="[{label, description, branch_id, status}]", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + # Relationships + session: Mapped["AISession"] = relationship("AISession") + parent_branch: Mapped["SessionBranch"] = relationship("SessionBranch") + trigger_step: Mapped[Optional["AISessionStep"]] = relationship("AISessionStep") +``` + +- [ ] **Step 2: Verify file was created correctly** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.models.fork_point import ForkPoint; print('OK:', ForkPoint.__tablename__)"` +Expected: `OK: fork_points` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/models/fork_point.py +git commit -m "feat: add ForkPoint model for branch decision points" +``` + +### Task 3: Create SessionHandoff Model + +**Files:** +- Create: `backend/app/models/session_handoff.py` + +- [ ] **Step 1: Write the SessionHandoff model** + +```python +# backend/app/models/session_handoff.py +"""Session handoff model — unified park/escalate with history.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any, TYPE_CHECKING + +from sqlalchemy import String, Text, 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.ai_session import AISession + from app.models.session_branch import SessionBranch + from app.models.user import User + + +class SessionHandoff(Base): + """A handoff event — either parking or escalating a session. + + Captures a snapshot of the session state at handoff time, including + branch map, AI assessment (for escalations), and artifacts. + Dual-writes to ai_sessions.escalation_package for backward compat. + """ + __tablename__ = "session_handoffs" + __table_args__ = ( + CheckConstraint( + "intent IN ('park', 'escalate')", + name="ck_session_handoffs_intent", + ), + CheckConstraint( + "priority IN ('normal', 'elevated')", + name="ck_session_handoffs_priority", + ), + ) + + 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, + ) + handed_off_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + intent: Mapped[str] = mapped_column(String(20), nullable=False) + source_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="SET NULL"), + nullable=True, + ) + snapshot: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict, + comment="Branch map, status, next step, waiting on, watch out", + ) + ai_assessment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ai_assessment_data: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, nullable=True, + comment="{likely_cause, suggested_steps, confidence}", + ) + artifacts: Mapped[Optional[list[dict[str, Any]]]] = mapped_column( + JSONB, nullable=True, + comment="[{name, type, reference}]", + ) + engineer_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + priority: Mapped[str] = mapped_column( + String(20), nullable=False, default="normal" + ) + claimed_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ) + claimed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + psa_note_pushed: Mapped[bool] = mapped_column(Boolean, default=False) + psa_note_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True + ) + notification_sent: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + # Relationships + session: Mapped["AISession"] = relationship("AISession") + handed_off_by_user: Mapped["User"] = relationship("User", foreign_keys=[handed_off_by]) + source_branch: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch") + claimed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[claimed_by]) +``` + +- [ ] **Step 2: Verify file was created correctly** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.models.session_handoff import SessionHandoff; print('OK:', SessionHandoff.__tablename__)"` +Expected: `OK: session_handoffs` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/models/session_handoff.py +git commit -m "feat: add SessionHandoff model for unified park/escalate" +``` + +### Task 4: Create SessionResolutionOutput Model + +**Files:** +- Create: `backend/app/models/session_resolution_output.py` + +- [ ] **Step 1: Write the SessionResolutionOutput model** + +```python +# backend/app/models/session_resolution_output.py +"""Session resolution output model — three deliverables generated on resolve.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, Any + +from sqlalchemy import String, Text, DateTime, ForeignKey, CheckConstraint, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB + +from app.core.database import Base + + +class SessionResolutionOutput(Base): + """One of three resolution deliverables: PSA ticket notes, KB article, client summary. + + Uses UNIQUE(session_id, output_type) + upsert pattern so outputs can be + regenerated if a session is re-opened after resolution. + """ + __tablename__ = "session_resolution_outputs" + __table_args__ = ( + CheckConstraint( + "output_type IN ('psa_ticket_notes', 'knowledge_base', 'client_summary')", + name="ck_session_resolution_outputs_output_type", + ), + CheckConstraint( + "status IN ('draft', 'approved', 'pushed', 'rejected')", + name="ck_session_resolution_outputs_status", + ), + UniqueConstraint("session_id", "output_type", name="uq_session_resolution_session_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, + ) + output_type: Mapped[str] = mapped_column(String(30), nullable=False) + generated_content: Mapped[str] = mapped_column(Text, nullable=False) + structured_data: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, nullable=True, + comment="For KB: {symptoms, root_cause, steps, tags}", + ) + edited_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft") + pushed_to: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + pushed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + pushed_reference: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + generated_by_model: Mapped[str] = mapped_column(String(50), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + # Relationships + session = relationship("AISession") +``` + +- [ ] **Step 2: Verify file was created correctly** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.models.session_resolution_output import SessionResolutionOutput; print('OK:', SessionResolutionOutput.__tablename__)"` +Expected: `OK: session_resolution_outputs` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/models/session_resolution_output.py +git commit -m "feat: add SessionResolutionOutput model for three-output resolution" +``` + +### Task 5: Add Columns to Existing Models + +**Files:** +- Modify: `backend/app/models/ai_session.py:25-221` — add 5 columns + relationships +- Modify: `backend/app/models/ai_session_step.py:21-134` — add 3 columns + update CHECK, add relationships +- Modify: `backend/app/models/file_upload.py:13-33` — add 5 columns + +- [ ] **Step 1: Add branching columns to AISession model** + +In `backend/app/models/ai_session.py`, add these columns after `conversation_messages` (line ~207) and before the Relationships section: + +```python + # ── Branching ── + is_branching: Mapped[bool] = mapped_column( + default=False, + comment="Whether conversational branching is active for this session", + ) + active_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), nullable=True, + comment="Currently viewed branch. No FK — soft pointer to avoid circular FK with session_branches", + ) + handoff_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Number of times this session has been handed off", + ) + total_active_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Cumulative active time in seconds", + ) + total_parked_seconds: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Cumulative parked time in seconds", + ) +``` + +Also add these TYPE_CHECKING imports at the top: + +```python + from app.models.session_branch import SessionBranch + from app.models.session_handoff import SessionHandoff + from app.models.session_resolution_output import SessionResolutionOutput +``` + +And add these relationships at the bottom of the class: + +```python + branches: Mapped[list["SessionBranch"]] = relationship( + "SessionBranch", + foreign_keys="SessionBranch.session_id", + cascade="all, delete-orphan", + order_by="SessionBranch.branch_order", + ) + handoffs: Mapped[list["SessionHandoff"]] = relationship( + "SessionHandoff", + cascade="all, delete-orphan", + order_by="SessionHandoff.created_at", + ) + resolution_outputs: Mapped[list["SessionResolutionOutput"]] = relationship( + "SessionResolutionOutput", + cascade="all, delete-orphan", + ) +``` + +- [ ] **Step 2: Add branching columns to AISessionStep model** + +In `backend/app/models/ai_session_step.py`: + +1. Update the CHECK constraint (line ~36) to include `'fork'`: + +```python + __table_args__ = ( + CheckConstraint( + "step_type IN ('question', 'action', 'script_generation', 'verification', " + "'info_request', 'note', 'intake_analysis', 'fork')", + name="ck_ai_session_steps_step_type", + ), + ) +``` + +2. Add these columns after `output_tokens` (line ~120): + +```python + # ── Branching ── + branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="SET NULL"), + nullable=True, + index=True, + comment="NULL = pre-branching/root messages", + ) + is_fork_point: Mapped[bool] = mapped_column( + default=False, + comment="Whether this step triggered a fork", + ) + fork_point_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("fork_points.id", ondelete="SET NULL"), + nullable=True, + ) +``` + +3. Add TYPE_CHECKING imports for `SessionBranch` and `ForkPoint`. + +- [ ] **Step 3: Add columns to FileUpload model** + +In `backend/app/models/file_upload.py`, add after `created_at` (line ~32): + +```python + # ── AI description + branching context ── + ai_description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="AI-generated one-sentence description of the file", + ) + extracted_content: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="Extracted text from logs/configs", + ) + content_summary: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, + comment="AI summary for long text files (>2000 tokens)", + ) + uploaded_on_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("session_branches.id", ondelete="SET NULL"), + nullable=True, + ) + uploaded_at_step_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("ai_session_steps.id", ondelete="SET NULL"), + nullable=True, + ) +``` + +Add the needed imports: `Text` from sqlalchemy, `ForeignKey` (already imported? check), `Optional` from typing. + +- [ ] **Step 4: Verify all model changes compile** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.models.ai_session import AISession; from app.models.ai_session_step import AISessionStep; from app.models.file_upload import FileUpload; print('All models OK')"` +Expected: `All models OK` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/models/ai_session.py backend/app/models/ai_session_step.py backend/app/models/file_upload.py +git commit -m "feat: add branching columns to ai_sessions, ai_session_steps, file_uploads" +``` + +### Task 6: Register Models in __init__.py and alembic env.py + +**Files:** +- Modify: `backend/app/models/__init__.py:1-117` +- Modify: `backend/alembic/env.py:10-28` + +- [ ] **Step 1: Add imports to models/__init__.py** + +Add after the `FileUpload` import (line 50): + +```python +from .session_branch import SessionBranch +from .fork_point import ForkPoint +from .session_handoff import SessionHandoff +from .session_resolution_output import SessionResolutionOutput +``` + +Add to `__all__` list: + +```python + "SessionBranch", + "ForkPoint", + "SessionHandoff", + "SessionResolutionOutput", +``` + +- [ ] **Step 2: Add imports to alembic/env.py** + +Add after the existing model imports (around line 27): + +```python +from app.models.session_branch import SessionBranch # noqa: F401 +from app.models.fork_point import ForkPoint # noqa: F401 +from app.models.session_handoff import SessionHandoff # noqa: F401 +from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401 +``` + +- [ ] **Step 3: Verify imports** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.models import SessionBranch, ForkPoint, SessionHandoff, SessionResolutionOutput; print('All imports OK')"` +Expected: `All imports OK` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/models/__init__.py backend/alembic/env.py +git commit -m "feat: register branching models in __init__ and alembic env" +``` + +### Task 7: Write Manual Alembic Migration + +**Files:** +- Create: `backend/alembic/versions/030_add_conversational_branching.py` + +**IMPORTANT:** This migration must be written manually per CLAUDE.md lesson 77. The CHECK constraint on `ai_session_steps.step_type` requires `DROP CONSTRAINT` then `ADD CONSTRAINT` — not additive. The `active_branch_id` on `ai_sessions` has NO FK constraint (to avoid circular FK with `session_branches`). + +- [ ] **Step 1: Write the migration file** + +```python +"""Add conversational branching tables and columns. + +New tables: session_branches, fork_points, session_handoffs, session_resolution_outputs +Modified: ai_sessions (5 cols), ai_session_steps (3 cols + CHECK), file_uploads (5 cols) + +Revision ID: 030 +Revises: (auto-detected) +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + + +# revision identifiers +revision = "030" +down_revision = None # Will be set by alembic — check `alembic heads` for current head +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── New table: session_branches ── + op.create_table( + "session_branches", + 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), + sa.Column("parent_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True), + sa.Column("fork_point_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True), + sa.Column("branch_order", sa.Integer, nullable=False, server_default="1"), + sa.Column("label", sa.String(200), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("status_reason", sa.Text, nullable=True), + sa.Column("status_changed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status_changed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("conversation_messages", JSONB, nullable=False, server_default="[]"), + sa.Column("context_summary", JSONB, nullable=True), + sa.Column("evidence_from_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("evidence_description", sa.Text, nullable=True), + 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.CheckConstraint("status IN ('active', 'dead_end', 'solved', 'untried', 'revived')", name="ck_session_branches_status"), + sa.CheckConstraint("branch_order > 0", name="ck_session_branches_branch_order_positive"), + ) + op.create_index("ix_session_branches_session_id", "session_branches", ["session_id"]) + op.create_index("ix_session_branches_parent_branch_id", "session_branches", ["parent_branch_id"]) + op.create_index("ix_session_branches_session_status", "session_branches", ["session_id", "status"]) + op.create_index("ix_session_branches_session_order", "session_branches", ["session_id", "branch_order"]) + + # ── New table: fork_points ── + op.create_table( + "fork_points", + 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), + sa.Column("parent_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False), + sa.Column("trigger_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True), + sa.Column("fork_reason", sa.Text, nullable=False), + sa.Column("options", JSONB, nullable=False, server_default="[]"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_fork_points_session_id", "fork_points", ["session_id"]) + + # ── New table: session_handoffs ── + op.create_table( + "session_handoffs", + 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), + sa.Column("handed_off_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("intent", sa.String(20), nullable=False), + sa.Column("source_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True), + sa.Column("snapshot", JSONB, nullable=False, server_default="{}"), + sa.Column("ai_assessment", sa.Text, nullable=True), + sa.Column("ai_assessment_data", JSONB, nullable=True), + sa.Column("artifacts", JSONB, nullable=True), + sa.Column("engineer_notes", sa.Text, nullable=True), + sa.Column("priority", sa.String(20), nullable=False, server_default="normal"), + sa.Column("claimed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("psa_note_pushed", sa.Boolean, server_default="false"), + sa.Column("psa_note_id", sa.String(100), nullable=True), + sa.Column("notification_sent", sa.Boolean, server_default="false"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.CheckConstraint("intent IN ('park', 'escalate')", name="ck_session_handoffs_intent"), + sa.CheckConstraint("priority IN ('normal', 'elevated')", name="ck_session_handoffs_priority"), + ) + op.create_index("ix_session_handoffs_session_id", "session_handoffs", ["session_id"]) + + # ── New table: session_resolution_outputs ── + op.create_table( + "session_resolution_outputs", + 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), + sa.Column("output_type", sa.String(30), nullable=False), + sa.Column("generated_content", sa.Text, nullable=False), + sa.Column("structured_data", JSONB, nullable=True), + sa.Column("edited_content", sa.Text, nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="draft"), + sa.Column("pushed_to", sa.String(50), nullable=True), + sa.Column("pushed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("pushed_reference", sa.String(200), nullable=True), + sa.Column("generated_by_model", sa.String(50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.CheckConstraint("output_type IN ('psa_ticket_notes', 'knowledge_base', 'client_summary')", name="ck_session_resolution_outputs_output_type"), + sa.CheckConstraint("status IN ('draft', 'approved', 'pushed', 'rejected')", name="ck_session_resolution_outputs_status"), + sa.UniqueConstraint("session_id", "output_type", name="uq_session_resolution_session_type"), + ) + op.create_index("ix_session_resolution_outputs_session_id", "session_resolution_outputs", ["session_id"]) + + # ── Modify ai_sessions: add 5 columns ── + op.add_column("ai_sessions", sa.Column("is_branching", sa.Boolean, server_default="false", nullable=False)) + op.add_column("ai_sessions", sa.Column("active_branch_id", UUID(as_uuid=True), nullable=True)) + op.add_column("ai_sessions", sa.Column("handoff_count", sa.Integer, server_default="0", nullable=False)) + op.add_column("ai_sessions", sa.Column("total_active_seconds", sa.Integer, server_default="0", nullable=False)) + op.add_column("ai_sessions", sa.Column("total_parked_seconds", sa.Integer, server_default="0", nullable=False)) + + # ── Modify ai_session_steps: add 3 columns + update CHECK constraint ── + op.add_column("ai_session_steps", sa.Column("branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)) + op.add_column("ai_session_steps", sa.Column("is_fork_point", sa.Boolean, server_default="false", nullable=False)) + op.add_column("ai_session_steps", sa.Column("fork_point_id", UUID(as_uuid=True), sa.ForeignKey("fork_points.id", ondelete="SET NULL"), nullable=True)) + op.create_index("ix_ai_session_steps_branch_id", "ai_session_steps", ["branch_id"]) + + # Drop and recreate step_type CHECK to add 'fork' + op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check") + op.create_check_constraint( + "ck_ai_session_steps_step_type", + "ai_session_steps", + "step_type IN ('question', 'action', 'script_generation', 'verification', " + "'info_request', 'note', 'intake_analysis', 'fork')", + ) + + # ── Modify file_uploads: add 5 columns ── + op.add_column("file_uploads", sa.Column("ai_description", sa.Text, nullable=True)) + op.add_column("file_uploads", sa.Column("extracted_content", sa.Text, nullable=True)) + op.add_column("file_uploads", sa.Column("content_summary", sa.Text, nullable=True)) + op.add_column("file_uploads", sa.Column("uploaded_on_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)) + op.add_column("file_uploads", sa.Column("uploaded_at_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)) + + +def downgrade() -> None: + # ── file_uploads columns ── + op.drop_column("file_uploads", "uploaded_at_step_id") + op.drop_column("file_uploads", "uploaded_on_branch_id") + op.drop_column("file_uploads", "content_summary") + op.drop_column("file_uploads", "extracted_content") + op.drop_column("file_uploads", "ai_description") + + # ── ai_session_steps: restore CHECK, drop columns ── + op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check") + op.create_check_constraint( + "ck_ai_session_steps_step_type", + "ai_session_steps", + "step_type IN ('question', 'action', 'script_generation', 'verification', " + "'info_request', 'note', 'intake_analysis')", + ) + op.drop_index("ix_ai_session_steps_branch_id", "ai_session_steps") + op.drop_column("ai_session_steps", "fork_point_id") + op.drop_column("ai_session_steps", "is_fork_point") + op.drop_column("ai_session_steps", "branch_id") + + # ── ai_sessions columns ── + op.drop_column("ai_sessions", "total_parked_seconds") + op.drop_column("ai_sessions", "total_active_seconds") + op.drop_column("ai_sessions", "handoff_count") + op.drop_column("ai_sessions", "active_branch_id") + op.drop_column("ai_sessions", "is_branching") + + # ── Drop new tables (reverse order of creation for FK deps) ── + op.drop_table("session_resolution_outputs") + op.drop_table("session_handoffs") + op.drop_table("fork_points") + op.drop_table("session_branches") +``` + +- [ ] **Step 2: Check current alembic head and set down_revision** + +Run: `cd /home/coder/resolutionflow/backend && alembic heads` +Set `down_revision` in the migration to the returned revision ID. + +- [ ] **Step 3: Run the migration** + +Run: `cd /home/coder/resolutionflow/backend && alembic upgrade head` +Expected: Migration applies without errors. + +- [ ] **Step 4: Verify tables exist** + +Run: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "\dt session_*"` +Expected: Shows `session_branches`, `session_handoffs`, `session_resolution_outputs`. + +Run: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_sessions" | grep -E "is_branching|active_branch_id|handoff_count"` +Expected: Shows the 3 new columns. + +- [ ] **Step 5: Commit** + +```bash +git add backend/alembic/versions/030_add_conversational_branching.py +git commit -m "feat: add conversational branching migration — 4 tables, 13 columns" +``` + +### Task 8: Create Pydantic Schemas + +**Files:** +- Create: `backend/app/schemas/session_branch.py` +- Create: `backend/app/schemas/session_handoff.py` +- Create: `backend/app/schemas/session_resolution.py` +- Modify: `backend/app/schemas/ai_session.py:197-265` + +- [ ] **Step 1: Write branch schemas** + +```python +# backend/app/schemas/session_branch.py +"""Pydantic schemas for session branches and fork points.""" +from __future__ import annotations + +from typing import Optional, Any +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + + +# ── Branch ── + +class BranchCreate(BaseModel): + """Used internally — branches are created via fork, not direct API.""" + label: str = Field(..., max_length=200) + status: str = "untried" + + +class BranchUpdate(BaseModel): + """Update branch status.""" + status: str = Field(..., pattern="^(active|dead_end|solved|untried|revived)$") + status_reason: str | None = None + + +class BranchResponse(BaseModel): + """Branch detail for API responses.""" + id: UUID + session_id: UUID + parent_branch_id: UUID | None + fork_point_step_id: UUID | None + branch_order: int + label: str + status: str + status_reason: str | None + status_changed_at: datetime | None + context_summary: dict[str, Any] | None + evidence_from_branch_id: UUID | None + evidence_description: str | None + step_count: int = 0 + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class BranchTreeResponse(BaseModel): + """Full branch tree for the BranchMap sidebar.""" + branches: list[BranchResponse] + active_branch_id: UUID | None + + +# ── Fork ── + +class ForkOption(BaseModel): + """One hypothesis option when creating a fork.""" + label: str = Field(..., max_length=200) + description: str = Field(..., max_length=500) + + +class ForkCreateRequest(BaseModel): + """Create a fork point with N branches.""" + fork_reason: str = Field(..., min_length=5, max_length=2000) + options: list[ForkOption] = Field(..., min_length=2, max_length=10) + + +class ForkPointResponse(BaseModel): + """Fork point detail.""" + id: UUID + session_id: UUID + parent_branch_id: UUID + trigger_step_id: UUID | None + fork_reason: str + options: list[dict[str, Any]] + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── Switch ── + +class BranchSwitchResponse(BaseModel): + """Response after switching branches.""" + active_branch_id: UUID + branch: BranchResponse + conversation_messages: list[dict[str, Any]] + + +# ── Revival ── + +class ReviveRequest(BaseModel): + """Revive a dead-end branch with new evidence.""" + evidence_from_branch_id: UUID + evidence_description: str = Field(..., min_length=5, max_length=2000) + + +# ── Branch message ── + +class BranchMessageRequest(BaseModel): + """Send a message on a specific branch.""" + message: str = Field(..., min_length=1, max_length=8000) + upload_ids: list[UUID] = Field(default_factory=list, max_length=10) + + +class BranchMessageResponse(BaseModel): + """AI response on a branch.""" + content: str + branch_id: UUID + step_id: UUID | None = None +``` + +- [ ] **Step 2: Write handoff schemas** + +```python +# backend/app/schemas/session_handoff.py +"""Pydantic schemas for session handoffs.""" +from __future__ import annotations + +from typing import Optional, Any +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + + +class HandoffCreateRequest(BaseModel): + """Create a handoff (park or escalate).""" + intent: str = Field(..., pattern="^(park|escalate)$") + engineer_notes: str | None = None + priority: str = Field("normal", pattern="^(normal|elevated)$") + + +class HandoffResponse(BaseModel): + """Handoff detail.""" + id: UUID + session_id: UUID + handed_off_by: UUID + intent: str + source_branch_id: UUID | None + snapshot: dict[str, Any] + ai_assessment: str | None + ai_assessment_data: dict[str, Any] | None + artifacts: list[dict[str, Any]] | None + engineer_notes: str | None + priority: str + claimed_by: UUID | None + claimed_at: datetime | None + psa_note_pushed: bool + notification_sent: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class HandoffClaimRequest(BaseModel): + """Claim a handed-off session.""" + pass # Just needs auth — claiming user comes from JWT + + +class HandoffBriefingResponse(BaseModel): + """Natural-language briefing for the claiming engineer.""" + briefing: str + handoff: HandoffResponse + + +class QueueItemResponse(BaseModel): + """Item in the team queue.""" + handoff_id: UUID + session_id: UUID + intent: str + problem_summary: str | None + problem_domain: str | None + priority: str + handed_off_by_name: str | None + engineer_notes: str | None + branch_count: int = 0 + created_at: datetime + claimed_by: UUID | None + claimed_at: datetime | None + + model_config = {"from_attributes": True} +``` + +- [ ] **Step 3: Write resolution output schemas** + +```python +# backend/app/schemas/session_resolution.py +"""Pydantic schemas for session resolution outputs.""" +from __future__ import annotations + +from typing import Optional, Any +from uuid import UUID +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ResolutionOutputResponse(BaseModel): + """Single resolution output.""" + id: UUID + session_id: UUID + output_type: str + generated_content: str + structured_data: dict[str, Any] | None + edited_content: str | None + status: str + pushed_to: str | None + pushed_at: datetime | None + pushed_reference: str | None + generated_by_model: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ResolutionOutputEditRequest(BaseModel): + """Edit output before pushing.""" + edited_content: str = Field(..., min_length=1) + + +class ResolutionOutputPushRequest(BaseModel): + """Push output to a destination.""" + destination: str = Field(..., pattern="^(psa|kb_library|clipboard|email)$") + + +class ResolutionOutputPushResponse(BaseModel): + """Result of pushing an output.""" + output_id: UUID + status: str + pushed_to: str + pushed_reference: str | None = None + + +class AllResolutionOutputsResponse(BaseModel): + """All three resolution outputs for a session.""" + outputs: list[ResolutionOutputResponse] +``` + +- [ ] **Step 4: Add branching fields to AISessionDetail** + +In `backend/app/schemas/ai_session.py`, add to `AISessionDetail` class (after `conversation_messages` field, line ~230): + +```python + is_branching: bool = False + active_branch_id: str | None = None +``` + +- [ ] **Step 5: Verify schemas compile** + +Run: `cd /home/coder/resolutionflow/backend && python -c "from app.schemas.session_branch import *; from app.schemas.session_handoff import *; from app.schemas.session_resolution import *; print('Schemas OK')"` +Expected: `Schemas OK` + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/schemas/session_branch.py backend/app/schemas/session_handoff.py backend/app/schemas/session_resolution.py backend/app/schemas/ai_session.py +git commit -m "feat: add Pydantic schemas for branching, handoffs, and resolution outputs" +``` + +--- + +## Phase 2: Branch Engine + +### Task 9: Write BranchManager Service — Tests First + +**Files:** +- Create: `backend/tests/test_branch_manager.py` +- Create: `backend/app/services/branch_manager.py` + +- [ ] **Step 1: Write failing tests for BranchManager** + +```python +# backend/tests/test_branch_manager.py +"""Integration tests for BranchManager service.""" +import uuid +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_branch import SessionBranch +from app.models.fork_point import ForkPoint +from app.models.ai_session_step import AISessionStep + + +@pytest.mark.asyncio +async def test_create_root_branch(client: AsyncClient, test_user, auth_headers, test_db): + """Creating a root branch sets is_branching=True and copies conversation_messages.""" + # Create a session first + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[ + {"role": "user", "content": "test message"}, + {"role": "assistant", "content": "test response"}, + ], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + assert root is not None + assert root.parent_branch_id is None + assert root.label == "Root" + assert root.status == "active" + assert root.branch_order == 1 + assert len(root.conversation_messages) == 2 + + # Session should now have is_branching=True + await test_db.refresh(session) + assert session.is_branching is True + assert session.active_branch_id == root.id + + +@pytest.mark.asyncio +async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db): + """Creating a fork produces a ForkPoint + N branches.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + # Create a trigger step + step = AISessionStep( + session_id=session.id, + step_order=0, + step_type="question", + content={"text": "What's the issue?"}, + confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.flush() + + fork_point, branches = await manager.create_fork( + session_id=session.id, + parent_branch_id=root.id, + trigger_step_id=step.id, + fork_reason="Two possible causes identified", + options=[ + {"label": "Network connectivity", "description": "Check network stack"}, + {"label": "DNS resolution", "description": "Check DNS config"}, + ], + ) + + assert fork_point is not None + assert len(branches) == 2 + assert branches[0].label == "Network connectivity" + assert branches[0].status == "untried" + assert branches[0].parent_branch_id == root.id + assert branches[1].label == "DNS resolution" + assert branches[1].branch_order == 2 + + # Trigger step should be marked as fork point + await test_db.refresh(step) + assert step.is_fork_point is True + assert step.fork_point_id == fork_point.id + + +@pytest.mark.asyncio +async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_db): + """Switching branches updates active_branch_id.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.flush() + + _, branches = await manager.create_fork( + session_id=session.id, + parent_branch_id=root.id, + trigger_step_id=step.id, + fork_reason="test fork", + options=[ + {"label": "Option A", "description": "desc A"}, + {"label": "Option B", "description": "desc B"}, + ], + ) + + branch_b = branches[1] + result = await manager.switch_branch(session.id, branch_b.id) + + assert result.id == branch_b.id + await test_db.refresh(session) + assert session.active_branch_id == branch_b.id + + +@pytest.mark.asyncio +async def test_mark_branch_dead_end(client: AsyncClient, test_user, auth_headers, test_db): + """Marking a branch as dead_end updates status.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + updated = await manager.mark_branch_status( + branch_id=root.id, + status="dead_end", + reason="Network was fine, not the cause", + user_id=test_user["user_data"]["id"], + ) + + assert updated.status == "dead_end" + assert updated.status_reason == "Network was fine, not the cause" + assert updated.status_changed_at is not None + + +@pytest.mark.asyncio +async def test_get_branch_tree(client: AsyncClient, test_user, auth_headers, test_db): + """get_branch_tree returns the full tree structure.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.flush() + + from app.services.branch_manager import BranchManager + manager = BranchManager(test_db) + root = await manager.create_root_branch(session.id) + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.flush() + + await manager.create_fork( + session_id=session.id, + parent_branch_id=root.id, + trigger_step_id=step.id, + fork_reason="test", + options=[ + {"label": "A", "description": "a"}, + {"label": "B", "description": "b"}, + ], + ) + + tree = await manager.get_branch_tree(session.id) + # Root + 2 fork branches = 3 total + assert len(tree) == 3 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_branch_manager.py -v --no-header 2>&1 | head -30` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.services.branch_manager'` + +- [ ] **Step 3: Implement BranchManager service** + +```python +# backend/app/services/branch_manager.py +"""Branch lifecycle management for conversational branching. + +Handles creating root branches, forking, switching, marking status, +reviving dead-end branches, and querying the branch tree. +""" +import uuid +import logging +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ai_session import AISession +from app.models.ai_session_step import AISessionStep +from app.models.session_branch import SessionBranch +from app.models.fork_point import ForkPoint + +logger = logging.getLogger(__name__) + + +class BranchManager: + """Branch lifecycle management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_root_branch(self, session_id: UUID) -> SessionBranch: + """Create the root branch, copy conversation_messages, set is_branching=True.""" + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + root = SessionBranch( + id=uuid.uuid4(), + session_id=session_id, + parent_branch_id=None, + branch_order=1, + label="Root", + status="active", + conversation_messages=list(session.conversation_messages or []), + ) + self.db.add(root) + + session.is_branching = True + session.active_branch_id = root.id + + await self.db.flush() + return root + + async def create_fork( + self, + session_id: UUID, + parent_branch_id: UUID, + trigger_step_id: UUID | None, + fork_reason: str, + options: list[dict[str, str]], + ) -> tuple[ForkPoint, list[SessionBranch]]: + """Create a fork point with N branches. + + Pre-generates all branch UUIDs, then inserts ForkPoint + N SessionBranch + rows in a single transaction. Enforces plan-tier branch limits. + """ + # Enforce branch limits by plan tier + from app.core.ai_quota_service import get_user_plan + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one() + existing_count_result = await self.db.execute( + select(func.count()).select_from(SessionBranch).where( + SessionBranch.session_id == session_id + ) + ) + existing_count = existing_count_result.scalar() or 0 + + # Plan limits: free=2, pro=5, team=10, enterprise=unlimited + BRANCH_LIMITS = {"free": 2, "pro": 5, "team": 10} + plan = await get_user_plan(session.user_id, self.db) + max_branches = BRANCH_LIMITS.get(plan, 999) + if existing_count + len(options) > max_branches: + raise ValueError( + f"Branch limit reached ({max_branches} for {plan} plan). " + f"Current: {existing_count}, requested: {len(options)}" + ) + + # Pre-generate branch IDs + branch_ids = [uuid.uuid4() for _ in options] + + # Build ForkPoint options JSONB with branch_ids + fork_options = [] + for i, opt in enumerate(options): + fork_options.append({ + "label": opt["label"], + "description": opt["description"], + "branch_id": str(branch_ids[i]), + "status": "untried", + }) + + fork_point = ForkPoint( + id=uuid.uuid4(), + session_id=session_id, + parent_branch_id=parent_branch_id, + trigger_step_id=trigger_step_id, + fork_reason=fork_reason, + options=fork_options, + ) + self.db.add(fork_point) + + # Get parent branch's conversation_messages for context + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == parent_branch_id) + ) + parent = result.scalar_one_or_none() + parent_messages = list(parent.conversation_messages or []) if parent else [] + + # Create branches + branches = [] + for i, opt in enumerate(options): + branch = SessionBranch( + id=branch_ids[i], + session_id=session_id, + parent_branch_id=parent_branch_id, + fork_point_step_id=trigger_step_id, + branch_order=i + 1, + label=opt["label"], + status="untried", + conversation_messages=parent_messages, + ) + self.db.add(branch) + branches.append(branch) + + # Mark trigger step as fork point + if trigger_step_id: + step_result = await self.db.execute( + select(AISessionStep).where(AISessionStep.id == trigger_step_id) + ) + step = step_result.scalar_one_or_none() + if step: + step.is_fork_point = True + step.fork_point_id = fork_point.id + + await self.db.flush() + return fork_point, branches + + async def switch_branch(self, session_id: UUID, target_branch_id: UUID) -> SessionBranch: + """Switch the active branch for a session.""" + result = await self.db.execute( + select(SessionBranch).where( + SessionBranch.id == target_branch_id, + SessionBranch.session_id == session_id, + ) + ) + branch = result.scalar_one_or_none() + if not branch: + raise ValueError(f"Branch {target_branch_id} not found in session {session_id}") + + session_result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = session_result.scalar_one() + session.active_branch_id = target_branch_id + + # Mark branch as active if it was untried + if branch.status == "untried": + branch.status = "active" + branch.status_changed_at = datetime.now(timezone.utc) + + await self.db.flush() + return branch + + async def mark_branch_status( + self, + branch_id: UUID, + status: str, + reason: str | None = None, + user_id: UUID | None = None, + ) -> SessionBranch: + """Update a branch's status with reason and timestamp.""" + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + raise ValueError(f"Branch {branch_id} not found") + + branch.status = status + branch.status_reason = reason + branch.status_changed_at = datetime.now(timezone.utc) + branch.status_changed_by = user_id + + await self.db.flush() + return branch + + async def revive_branch( + self, + branch_id: UUID, + evidence_from_branch_id: UUID, + evidence_description: str, + ) -> SessionBranch: + """Revive a dead-end branch with evidence from another branch.""" + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + raise ValueError(f"Branch {branch_id} not found") + + branch.status = "revived" + branch.status_changed_at = datetime.now(timezone.utc) + branch.evidence_from_branch_id = evidence_from_branch_id + branch.evidence_description = evidence_description + + # Prepend revival context to conversation + revival_msg = { + "role": "system", + "content": f"[Branch Revived] New evidence from another branch: {evidence_description}", + } + msgs = list(branch.conversation_messages or []) + msgs.append(revival_msg) + branch.conversation_messages = msgs + + await self.db.flush() + return branch + + async def get_branch_tree(self, session_id: UUID) -> list[SessionBranch]: + """Get all branches for a session, ordered by branch_order.""" + result = await self.db.execute( + select(SessionBranch) + .where(SessionBranch.session_id == session_id) + .order_by(SessionBranch.branch_order) + ) + return list(result.scalars().all()) + + async def build_cross_branch_context(self, branch_id: UUID) -> str: + """Build cross-branch context from sibling summaries. + + Reads context_summary from all branches in the same session + (excluding the current branch), prioritized: + active > untried > revived > dead_end. + """ + result = await self.db.execute( + select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + return "" + + siblings_result = await self.db.execute( + select(SessionBranch) + .where( + SessionBranch.session_id == branch.session_id, + SessionBranch.id != branch_id, + ) + .order_by(SessionBranch.branch_order) + ) + siblings = list(siblings_result.scalars().all()) + + if not siblings: + return "" + + # Priority order + priority = {"active": 0, "untried": 1, "revived": 2, "dead_end": 3, "solved": 4} + siblings.sort(key=lambda b: priority.get(b.status, 5)) + + parts = ["\n## Cross-Branch Context"] + for sib in siblings: + summary = sib.context_summary + if summary: + tried = ", ".join(summary.get("tried", [])) + concluded = summary.get("concluded", "No conclusion yet") + parts.append(f"- **{sib.label}** [{sib.status}]: Tried: {tried}. {concluded}") + else: + parts.append(f"- **{sib.label}** [{sib.status}]: No summary yet.") + + return "\n".join(parts) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_branch_manager.py -v --no-header` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/tests/test_branch_manager.py backend/app/services/branch_manager.py +git commit -m "feat: add BranchManager service with integration tests" +``` + +### Task 10: Write BranchAwarePromptBuilder — Tests First + +**Files:** +- Create: `backend/tests/test_branch_aware_prompt_builder.py` +- Create: `backend/app/services/branch_aware_prompt_builder.py` + +- [ ] **Step 1: Write failing tests for BranchAwarePromptBuilder** + +```python +# backend/tests/test_branch_aware_prompt_builder.py +"""Unit tests for BranchAwarePromptBuilder — pure function, no DB needed.""" +import pytest + +from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder + + +def test_build_basic(): + """Basic build with no cross-branch context.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[ + {"role": "user", "content": "DNS not resolving"}, + {"role": "assistant", "content": "Let's check DNS config"}, + ], + sibling_summaries="", + session_context="Problem: DNS resolution failure. Domain: networking.", + attachments=[], + new_message="I ran nslookup and got timeout", + ) + + assert "system_base" in result + assert "rag_context" in result + assert "history" in result + assert "new_message" in result + assert "images" in result + assert result["new_message"] == "I ran nslookup and got timeout" + # History should NOT include the new_message + assert len(result["history"]) == 2 + + +def test_build_with_cross_branch_context(): + """Cross-branch summaries go into rag_context, not system_base.""" + builder = BranchAwarePromptBuilder() + sibling_ctx = ( + "\n## Cross-Branch Context\n" + "- **Network connectivity** [dead_end]: Tried: ping, traceroute. Network was fine." + ) + result = builder.build( + branch_messages=[], + sibling_summaries=sibling_ctx, + session_context="Problem: test", + attachments=[], + new_message="test message", + ) + + assert "Cross-Branch Context" in result["rag_context"] + assert "Cross-Branch Context" not in result["system_base"] + + +def test_build_with_images(): + """Image attachments are passed through.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[], + sibling_summaries="", + session_context="Problem: test", + attachments=[{"media_type": "image/png", "data": "base64data"}], + new_message="check this screenshot", + ) + + assert len(result["images"]) == 1 + assert result["images"][0]["media_type"] == "image/png" + + +def test_build_with_revival_context(): + """Revival context is prepended to rag_context.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[], + sibling_summaries="", + session_context="Problem: test", + attachments=[], + new_message="test", + revival_context="New evidence: the error appears when VPN is active", + ) + + assert "New evidence" in result["rag_context"] + + +def test_history_excludes_last_user_message_when_matching(): + """If the last message in branch_messages matches new_message, it's excluded from history.""" + builder = BranchAwarePromptBuilder() + result = builder.build( + branch_messages=[ + {"role": "user", "content": "first message"}, + {"role": "assistant", "content": "first response"}, + ], + sibling_summaries="", + session_context="Problem: test", + attachments=[], + new_message="second message", + ) + + # All branch messages should be in history (none match new_message) + assert len(result["history"]) == 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_branch_aware_prompt_builder.py -v --no-header 2>&1 | head -20` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement BranchAwarePromptBuilder** + +```python +# backend/app/services/branch_aware_prompt_builder.py +"""Branch-aware prompt builder — assembles AI context with cross-branch awareness. + +Pure function: takes data, returns dict matching _call_ai parameter names. +No DB access, no LLM calls. The caller pre-fetches all data. + +Return keys: system_base, rag_context, history, new_message, images +- system_base: stable system prompt (cached by Anthropic) +- rag_context: cross-branch summaries + attachment descriptions (NOT cached) +""" +from typing import Any + +from app.services.assistant_chat_service import ASSISTANT_SYSTEM_PROMPT + + +class BranchAwarePromptBuilder: + """Assembles prompt components for branch-aware AI calls.""" + + def build( + self, + branch_messages: list[dict[str, Any]], + sibling_summaries: str, + session_context: str, + attachments: list[dict[str, Any]], + new_message: str, + revival_context: str | None = None, + token_budget: int = 100_000, + ) -> dict[str, Any]: + """Build prompt components for _call_ai. + + Args: + branch_messages: Conversation history for the current branch. + sibling_summaries: Cross-branch context from BranchManager.build_cross_branch_context. + session_context: Problem summary, domain, client info, PSA data. + attachments: Image dicts [{media_type, data}] from current branch uploads. + new_message: The user's current message. + revival_context: If branch was revived, the evidence description. + token_budget: Max tokens for the entire prompt (default 100k). + + Returns: + Dict with keys matching _call_ai params: system_base, rag_context, + history, new_message, images. + """ + # 1. system_base — stable, cached across turns + system_base = ASSISTANT_SYSTEM_PROMPT + "\n\n## Session Context\n" + session_context + + # 2. rag_context — changes per query, NOT cached + rag_parts = [] + if revival_context: + rag_parts.append(f"\n## Branch Revival\n{revival_context}") + if sibling_summaries: + rag_parts.append(sibling_summaries) + rag_context = "\n".join(rag_parts) + + # 3. history — branch messages as role/content dicts + history = [] + for msg in branch_messages: + if msg.get("role") in ("user", "assistant"): + history.append({"role": msg["role"], "content": msg["content"]}) + + # 4. images + images = attachments if attachments else None + + return { + "system_base": system_base, + "rag_context": rag_context, + "history": history, + "new_message": new_message, + "images": images, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_branch_aware_prompt_builder.py -v --no-header` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/tests/test_branch_aware_prompt_builder.py backend/app/services/branch_aware_prompt_builder.py +git commit -m "feat: add BranchAwarePromptBuilder with unit tests" +``` + +### Task 11: Write Branch API Endpoints + +**Files:** +- Create: `backend/app/api/endpoints/session_branches.py` +- Modify: `backend/app/api/router.py:1-88` +- Create: `backend/tests/test_session_branches_api.py` + +- [ ] **Step 1: Write failing API tests** + +```python +# backend/tests/test_session_branches_api.py +"""API endpoint tests for session branches.""" +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.ai_session_step import AISessionStep + + +@pytest.mark.asyncio +async def test_list_branches_empty(client: AsyncClient, test_user, auth_headers, test_db): + """GET /ai-sessions/{id}/branches returns empty for non-branching session.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + resp = await client.get( + f"/api/v1/ai-sessions/{session.id}/branches", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["branches"] == [] + assert data["active_branch_id"] is None + + +@pytest.mark.asyncio +async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/branches/fork creates branches.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.flush() + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.commit() + + resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/branches/fork", + headers=auth_headers, + json={ + "fork_reason": "Two possible causes", + "options": [ + {"label": "Network issue", "description": "Check connectivity"}, + {"label": "DNS problem", "description": "Check DNS"}, + ], + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert len(data["options"]) == 2 + + +@pytest.mark.asyncio +async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/branches/{bid}/switch changes active branch.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.flush() + + step = AISessionStep( + session_id=session.id, step_order=0, step_type="question", + content={"text": "test"}, confidence_at_step=0.5, + ) + test_db.add(step) + await test_db.commit() + + # Create fork first + fork_resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/branches/fork", + headers=auth_headers, + json={ + "fork_reason": "test", + "options": [ + {"label": "A", "description": "a"}, + {"label": "B", "description": "b"}, + ], + }, + ) + fork_data = fork_resp.json() + branch_b_id = fork_data["options"][1]["branch_id"] + + # Switch to branch B + resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/branches/{branch_b_id}/switch", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["active_branch_id"] == branch_b_id +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_session_branches_api.py -v --no-header 2>&1 | head -20` +Expected: FAIL — route not found (404). + +- [ ] **Step 3: Implement session_branches endpoint** + +```python +# backend/app/api/endpoints/session_branches.py +"""Branch management endpoints for conversational branching. + + GET /ai-sessions/{id}/branches — List all branches (tree) + POST /ai-sessions/{id}/branches/fork — Create fork with N branches + PATCH /ai-sessions/{id}/branches/{bid} — Update branch status + POST /ai-sessions/{id}/branches/{bid}/switch — Switch active branch + POST /ai-sessions/{id}/branches/{bid}/revive — Revive dead-end branch + POST /ai-sessions/{id}/branches/{bid}/message — Send message on branch +""" +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_session import AISession +from app.services.branch_manager import BranchManager +from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder +from app.services.assistant_chat_service import _call_ai +from app.schemas.session_branch import ( + BranchTreeResponse, + BranchResponse, + BranchUpdate, + ForkCreateRequest, + ForkPointResponse, + BranchSwitchResponse, + ReviveRequest, + BranchMessageRequest, + BranchMessageResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ai-sessions/{session_id}/branches", tags=["session-branches"]) + + +async def _get_user_session( + session_id: UUID, user: User, db: AsyncSession +) -> AISession: + """Fetch session owned by user, or raise 404.""" + result = await db.execute( + select(AISession).where( + AISession.id == session_id, + AISession.user_id == user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found") + return session + + +@router.get("", response_model=BranchTreeResponse) +async def list_branches( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchTreeResponse: + """Get branch tree for a session.""" + session = await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + branches = await manager.get_branch_tree(session_id) + + branch_responses = [] + for b in branches: + branch_responses.append(BranchResponse( + id=b.id, + session_id=b.session_id, + parent_branch_id=b.parent_branch_id, + fork_point_step_id=b.fork_point_step_id, + branch_order=b.branch_order, + label=b.label, + status=b.status, + status_reason=b.status_reason, + status_changed_at=b.status_changed_at, + context_summary=b.context_summary, + evidence_from_branch_id=b.evidence_from_branch_id, + evidence_description=b.evidence_description, + created_at=b.created_at, + updated_at=b.updated_at, + )) + + return BranchTreeResponse( + branches=branch_responses, + active_branch_id=session.active_branch_id, + ) + + +@router.post("/fork", response_model=ForkPointResponse, status_code=status.HTTP_201_CREATED) +async def create_fork( + session_id: UUID, + body: ForkCreateRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ForkPointResponse: + """Create a fork point with N branches.""" + session = await _get_user_session(session_id, current_user, db) + + if session.status not in ("active", "paused"): + raise HTTPException(status_code=400, detail=f"Cannot fork a {session.status} session") + + manager = BranchManager(db) + + # Ensure branching is initialized + if not session.is_branching: + await manager.create_root_branch(session_id) + await db.refresh(session) + + # Use the active branch as parent + parent_branch_id = session.active_branch_id + if not parent_branch_id: + raise HTTPException(status_code=400, detail="No active branch to fork from") + + options = [{"label": o.label, "description": o.description} for o in body.options] + + fork_point, branches = await manager.create_fork( + session_id=session_id, + parent_branch_id=parent_branch_id, + trigger_step_id=None, # Could be set from last step + fork_reason=body.fork_reason, + options=options, + ) + + await db.commit() + return ForkPointResponse.model_validate(fork_point) + + +@router.patch("/{branch_id}", response_model=BranchResponse) +async def update_branch_status( + session_id: UUID, + branch_id: UUID, + body: BranchUpdate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchResponse: + """Update a branch's status.""" + await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + + try: + branch = await manager.mark_branch_status( + branch_id=branch_id, + status=body.status, + reason=body.status_reason, + user_id=current_user.id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return BranchResponse.model_validate(branch) + + +@router.post("/{branch_id}/switch", response_model=BranchSwitchResponse) +async def switch_branch( + session_id: UUID, + branch_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchSwitchResponse: + """Switch the active branch.""" + await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + + try: + branch = await manager.switch_branch(session_id, branch_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return BranchSwitchResponse( + active_branch_id=branch.id, + branch=BranchResponse.model_validate(branch), + conversation_messages=branch.conversation_messages, + ) + + +@router.post("/{branch_id}/revive", response_model=BranchResponse) +async def revive_branch( + session_id: UUID, + branch_id: UUID, + body: ReviveRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchResponse: + """Revive a dead-end branch with new evidence.""" + await _get_user_session(session_id, current_user, db) + manager = BranchManager(db) + + try: + branch = await manager.revive_branch( + branch_id=branch_id, + evidence_from_branch_id=body.evidence_from_branch_id, + evidence_description=body.evidence_description, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return BranchResponse.model_validate(branch) + + +@router.post("/{branch_id}/message", response_model=BranchMessageResponse) +async def send_branch_message( + session_id: UUID, + branch_id: UUID, + body: BranchMessageRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> BranchMessageResponse: + """Send a message on a specific branch.""" + session = await _get_user_session(session_id, current_user, db) + + if session.status not in ("active", "paused"): + raise HTTPException(status_code=400, detail=f"Cannot message a {session.status} session") + + manager = BranchManager(db) + + # Switch to branch if not already active + if session.active_branch_id != branch_id: + await manager.switch_branch(session_id, branch_id) + await db.refresh(session) + + # Get branch + from sqlalchemy import select as sa_select + from app.models.session_branch import SessionBranch + result = await db.execute( + sa_select(SessionBranch).where(SessionBranch.id == branch_id) + ) + branch = result.scalar_one_or_none() + if not branch: + raise HTTPException(status_code=404, detail="Branch not found") + + # Build cross-branch context + sibling_ctx = await manager.build_cross_branch_context(branch_id) + + # Build prompt + builder = BranchAwarePromptBuilder() + session_context = f"Problem: {session.problem_summary or 'Unknown'}. Domain: {session.problem_domain or 'Unknown'}." + prompt_args = builder.build( + branch_messages=branch.conversation_messages, + sibling_summaries=sibling_ctx, + session_context=session_context, + attachments=[], # TODO: fetch image attachments for this branch + new_message=body.message, + revival_context=branch.evidence_description if branch.status == "revived" else None, + ) + + # Call AI + ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args) + + # Update branch conversation + msgs = list(branch.conversation_messages or []) + msgs.append({"role": "user", "content": body.message}) + msgs.append({"role": "assistant", "content": ai_content}) + branch.conversation_messages = msgs + + # Update session token counts + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + + # Resume if paused + if session.status == "paused": + session.status = "active" + + await db.commit() + + return BranchMessageResponse( + content=ai_content, + branch_id=branch_id, + ) +``` + +- [ ] **Step 4: Register router in router.py** + +In `backend/app/api/router.py`, add: + +```python +from app.api.endpoints import session_branches +``` + +And: + +```python +api_router.include_router(session_branches.router) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_session_branches_api.py -v --no-header` +Expected: All 3 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/api/endpoints/session_branches.py backend/tests/test_session_branches_api.py backend/app/api/router.py +git commit -m "feat: add branch API endpoints with integration tests" +``` + +### Task 12: Integrate Branching Into Existing Chat Service + +**Files:** +- Modify: `backend/app/services/unified_chat_service.py:54-132` +- Modify: `backend/app/services/flowpilot_engine.py` (step creation) + +- [ ] **Step 1: Add is_branching guard to unified_chat_service.send_chat_message** + +In `backend/app/services/unified_chat_service.py`, after the session status check (line ~82), add: + +```python + # If branching is active, route to branch message handler + if session.is_branching and session.active_branch_id: + from app.services.branch_manager import BranchManager + from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder + from app.models.session_branch import SessionBranch + + branch_result = await db.execute( + select(SessionBranch).where(SessionBranch.id == session.active_branch_id) + ) + branch = branch_result.scalar_one_or_none() + if branch: + manager = BranchManager(db) + sibling_ctx = await manager.build_cross_branch_context(branch.id) + + builder = BranchAwarePromptBuilder() + session_context = f"Problem: {session.problem_summary or 'Unknown'}. Domain: {session.problem_domain or 'Unknown'}." + prompt_args = builder.build( + branch_messages=branch.conversation_messages, + sibling_summaries=sibling_ctx, + session_context=session_context, + attachments=[], + new_message=message, + revival_context=branch.evidence_description if branch.status == "revived" else None, + ) + + # Override images from prompt_args with actual images if provided + if images: + prompt_args["images"] = images + ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args) + + # Update branch conversation + msgs = list(branch.conversation_messages or []) + msgs.append({"role": "user", "content": message}) + msgs.append({"role": "assistant", "content": ai_content}) + branch.conversation_messages = msgs + + session.total_input_tokens += input_tokens + session.total_output_tokens += output_tokens + session.step_count += 2 + + if session.status == "paused": + session.status = "active" + + suggested_flows = extract_suggested_flows( + await rag_search(query=message, account_id=account_id, db=db, limit=8) + ) + return ai_content, suggested_flows, session +``` + +- [ ] **Step 2: Add branch_id to step creation in flowpilot_engine** + +In `backend/app/services/flowpilot_engine.py`, find the `AISessionStep(` constructor calls (around lines 640, 926, 1244). In each, add after `session_id=session.id`: + +```python + branch_id=session.active_branch_id if session.is_branching else None, +``` + +This is a one-line addition in each of three places. + +- [ ] **Step 3: Verify existing tests still pass** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_ai_sessions.py tests/test_ai_chat.py -v --no-header 2>&1 | tail -20` +Expected: All existing tests still pass (branching path not triggered). + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/unified_chat_service.py backend/app/services/flowpilot_engine.py +git commit -m "feat: integrate branching into chat service and step creation" +``` + +--- + +## Phase 3: Handoff System + +### Task 13: Write HandoffManager Service — Tests First + +**Files:** +- Create: `backend/tests/test_handoff_manager.py` +- Create: `backend/app/services/handoff_manager.py` + +- [ ] **Step 1: Write failing tests** + +```python +# backend/tests/test_handoff_manager.py +"""Integration tests for HandoffManager service.""" +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession + + +@pytest.mark.asyncio +async def test_create_park_handoff(client: AsyncClient, test_user, auth_headers, test_db): + """Parking a session creates a handoff with snapshot.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[{"role": "user", "content": "help me"}], + ) + test_db.add(session) + await test_db.flush() + + from app.services.handoff_manager import HandoffManager + manager = HandoffManager(test_db) + + handoff = await manager.create_handoff( + session_id=session.id, + intent="park", + engineer_notes="Waiting for client to provide logs", + user_id=test_user["user_data"]["id"], + ) + + assert handoff is not None + assert handoff.intent == "park" + assert handoff.engineer_notes == "Waiting for client to provide logs" + assert handoff.snapshot is not None + + # Session should be paused + await test_db.refresh(session) + assert session.status == "paused" + assert session.handoff_count == 1 + + +@pytest.mark.asyncio +async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_headers, test_db): + """Escalating creates handoff + dual-writes to escalation_package.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.handoff_manager import HandoffManager + manager = HandoffManager(test_db) + + handoff = await manager.create_handoff( + session_id=session.id, + intent="escalate", + engineer_notes="Need senior help", + user_id=test_user["user_data"]["id"], + ) + + assert handoff.intent == "escalate" + + # Dual-write check + await test_db.refresh(session) + assert session.status == "escalated" + assert session.escalation_package is not None + assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package + + +@pytest.mark.asyncio +async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_headers, test_db): + """Claiming a handoff sets claimed_by and reactivates session.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.flush() + + from app.services.handoff_manager import HandoffManager + manager = HandoffManager(test_db) + + handoff = await manager.create_handoff( + session_id=session.id, + intent="escalate", + engineer_notes="Need help", + user_id=test_user["user_data"]["id"], + ) + + claimed = await manager.claim_session( + handoff_id=handoff.id, + claiming_user_id=test_admin["user_data"]["id"], + ) + + assert claimed.claimed_by == test_admin["user_data"]["id"] + assert claimed.claimed_at is not None + + await test_db.refresh(session) + assert session.status == "active" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_handoff_manager.py -v --no-header 2>&1 | head -15` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement HandoffManager** + +```python +# backend/app/services/handoff_manager.py +"""Handoff management — unified park/escalate with dual-write backward compat. + +Creates handoff snapshots, AI assessments (for escalations), claim workflow, +and queue queries. Dual-writes to ai_sessions.escalation_package for +backward compatibility with the existing escalation queue. +""" +import logging +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.ai_session import AISession +from app.models.session_branch import SessionBranch +from app.models.session_handoff import SessionHandoff + +logger = logging.getLogger(__name__) + + +class HandoffManager: + """Unified park/escalate handoff management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_handoff( + self, + session_id: UUID, + intent: str, + engineer_notes: str | None, + user_id: UUID, + priority: str = "normal", + ) -> SessionHandoff: + """Create a handoff (park or escalate). + + Generates snapshot, updates session status, dual-writes to + escalation_package for backward compat. + """ + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + # Generate snapshot + snapshot = await self._generate_snapshot(session) + + # Generate AI assessment for escalations + ai_assessment = None + ai_assessment_data = None + if intent == "escalate": + ai_assessment, ai_assessment_data = await self._generate_ai_assessment(session) + + handoff = SessionHandoff( + session_id=session_id, + handed_off_by=user_id, + intent=intent, + source_branch_id=session.active_branch_id, + snapshot=snapshot, + ai_assessment=ai_assessment, + ai_assessment_data=ai_assessment_data, + engineer_notes=engineer_notes, + priority=priority, + ) + self.db.add(handoff) + + # Update session status + if intent == "park": + session.status = "paused" + elif intent == "escalate": + session.status = "escalated" + + session.handoff_count = (session.handoff_count or 0) + 1 + + # Dual-write for backward compat + session.escalation_package = { + "snapshot": snapshot, + "intent": intent, + "engineer_notes": engineer_notes, + "handoff_id": str(handoff.id), + } + + await self.db.flush() + return handoff + + async def _generate_snapshot(self, session: AISession) -> dict[str, Any]: + """Generate a snapshot of the session state at handoff time.""" + snapshot: dict[str, Any] = { + "problem_summary": session.problem_summary, + "problem_domain": session.problem_domain, + "status": session.status, + "step_count": session.step_count, + "confidence_tier": session.confidence_tier, + } + + # Add branch map if branching is active + if session.is_branching: + branches_result = await self.db.execute( + select(SessionBranch) + .where(SessionBranch.session_id == session.id) + .order_by(SessionBranch.branch_order) + ) + branches = list(branches_result.scalars().all()) + + branch_map = [] + for b in branches: + branch_map.append({ + "id": str(b.id), + "label": b.label, + "status": b.status, + "status_reason": b.status_reason, + "parent_branch_id": str(b.parent_branch_id) if b.parent_branch_id else None, + }) + snapshot["branch_map"] = branch_map + snapshot["active_branch_id"] = str(session.active_branch_id) if session.active_branch_id else None + + return snapshot + + async def claim_session( + self, + handoff_id: UUID, + claiming_user_id: UUID, + ) -> SessionHandoff: + """Claim a handed-off session.""" + result = await self.db.execute( + select(SessionHandoff).where(SessionHandoff.id == handoff_id) + ) + handoff = result.scalar_one_or_none() + if not handoff: + raise ValueError(f"Handoff {handoff_id} not found") + + handoff.claimed_by = claiming_user_id + handoff.claimed_at = datetime.now(timezone.utc) + + # Reactivate session + session_result = await self.db.execute( + select(AISession).where(AISession.id == handoff.session_id) + ) + session = session_result.scalar_one() + session.status = "active" + + # Dual-write + session.escalated_to_id = claiming_user_id + + await self.db.flush() + return handoff + + async def _generate_ai_assessment( + self, session: AISession + ) -> tuple[str | None, dict[str, Any] | None]: + """Generate AI diagnostic assessment for escalation handoffs.""" + try: + from app.services.assistant_chat_service import _call_ai + + context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}" + msgs = session.conversation_messages or [] + # Include last 10 messages for context + recent = "\n".join( + f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}" + for m in msgs[-10:] + ) + + assessment_text, _, _ = await _call_ai( + system_base="You are a diagnostic assessment generator for MSP escalations.", + rag_context="", + history=[], + new_message=( + f"Generate a brief diagnostic assessment for this escalation.\n" + f"{context}\n\nRecent conversation:\n{recent}\n\n" + f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)" + ), + max_tokens=500, + ) + + assessment_data = { + "likely_cause": "See assessment text", + "suggested_steps": [], + "confidence": "medium", + } + + return assessment_text, assessment_data + except Exception: + logger.exception("Failed to generate AI assessment") + return None, None + + async def generate_briefing( + self, handoff_id: UUID, claiming_user_id: UUID + ) -> str: + """Generate a natural-language briefing for the engineer claiming the session.""" + result = await self.db.execute( + select(SessionHandoff).where(SessionHandoff.id == handoff_id) + ) + handoff = result.scalar_one_or_none() + if not handoff: + raise ValueError(f"Handoff {handoff_id} not found") + + session_result = await self.db.execute( + select(AISession).where(AISession.id == handoff.session_id) + ) + session = session_result.scalar_one() + + from app.services.assistant_chat_service import _call_ai + + snapshot_text = str(handoff.snapshot)[:2000] + briefing, _, _ = await _call_ai( + system_base="You are a handoff briefing generator for MSP teams.", + rag_context="", + history=[], + new_message=( + f"Generate a concise briefing for an engineer picking up this session.\n" + f"Problem: {session.problem_summary}\n" + f"Intent: {handoff.intent}\n" + f"Engineer notes: {handoff.engineer_notes or 'None'}\n" + f"Snapshot: {snapshot_text}\n" + f"AI Assessment: {handoff.ai_assessment or 'None'}" + ), + max_tokens=500, + ) + return briefing + + async def push_to_psa(self, handoff_id: UUID) -> SessionHandoff: + """Push handoff notes to PSA via existing psa_documentation_service.""" + result = await self.db.execute( + select(SessionHandoff).where(SessionHandoff.id == handoff_id) + ) + handoff = result.scalar_one_or_none() + if not handoff: + raise ValueError(f"Handoff {handoff_id} not found") + + # Route to existing PSA documentation service + try: + from app.services.psa_documentation_service import push_session_notes + session_result = await self.db.execute( + select(AISession).where(AISession.id == handoff.session_id) + ) + session = session_result.scalar_one() + if session.psa_ticket_id and session.psa_connection_id: + note_id = await push_session_notes( + session=session, + notes_content=handoff.ai_assessment or str(handoff.snapshot), + db=self.db, + ) + handoff.psa_note_pushed = True + handoff.psa_note_id = note_id + except Exception: + logger.exception(f"Failed to push handoff {handoff_id} to PSA") + + await self.db.flush() + return handoff + + async def get_queue( + self, + team_id: UUID | None = None, + account_id: UUID | None = None, + ) -> list[dict[str, Any]]: + """Get team queue of parked + escalated sessions.""" + query = ( + select(SessionHandoff, AISession) + .join(AISession, SessionHandoff.session_id == AISession.id) + .where(SessionHandoff.claimed_by.is_(None)) + .order_by(SessionHandoff.created_at.desc()) + ) + + if team_id: + query = query.where(AISession.team_id == team_id) + elif account_id: + query = query.where(AISession.account_id == account_id) + + result = await self.db.execute(query) + rows = result.all() + + queue_items = [] + for handoff, session in rows: + queue_items.append({ + "handoff_id": handoff.id, + "session_id": session.id, + "intent": handoff.intent, + "problem_summary": session.problem_summary, + "problem_domain": session.problem_domain, + "priority": handoff.priority, + "engineer_notes": handoff.engineer_notes, + "created_at": handoff.created_at, + "claimed_by": handoff.claimed_by, + "claimed_at": handoff.claimed_at, + }) + + return queue_items +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_handoff_manager.py -v --no-header` +Expected: All 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/tests/test_handoff_manager.py backend/app/services/handoff_manager.py +git commit -m "feat: add HandoffManager service with dual-write and integration tests" +``` + +### Task 14: Write Handoff API Endpoints + +**Files:** +- Create: `backend/app/api/endpoints/session_handoffs.py` +- Modify: `backend/app/api/router.py` +- Create: `backend/tests/test_session_handoffs_api.py` + +- [ ] **Step 1: Write failing API tests** + +```python +# backend/tests/test_session_handoffs_api.py +"""API endpoint tests for session handoffs.""" +import pytest +from httpx import AsyncClient + +from app.models.ai_session import AISession + + +@pytest.mark.asyncio +async def test_create_park_handoff_api(client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/handoff with intent=park.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + resp = await client.post( + f"/api/v1/ai-sessions/{session.id}/handoff", + headers=auth_headers, + json={"intent": "park", "engineer_notes": "Waiting for logs"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["intent"] == "park" + + +@pytest.mark.asyncio +async def test_get_queue(client: AsyncClient, test_user, auth_headers, test_db): + """GET /ai-sessions/queue returns unclaimed handoffs.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="discovery", + conversation_messages=[], + ) + test_db.add(session) + await test_db.commit() + + # Create a handoff + await client.post( + f"/api/v1/ai-sessions/{session.id}/handoff", + headers=auth_headers, + json={"intent": "escalate", "engineer_notes": "Need help"}, + ) + + resp = await client.get("/api/v1/ai-sessions/queue", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 +``` + +- [ ] **Step 2: Implement session_handoffs endpoint** + +```python +# backend/app/api/endpoints/session_handoffs.py +"""Handoff endpoints — unified park/escalate. + + POST /ai-sessions/{id}/handoff — Create handoff + GET /ai-sessions/{id}/handoffs — Handoff history + POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session + GET /ai-sessions/queue — Team queue +""" +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_session import AISession +from app.models.session_handoff import SessionHandoff +from app.services.handoff_manager import HandoffManager +from app.schemas.session_handoff import ( + HandoffCreateRequest, + HandoffResponse, +) + +logger = logging.getLogger(__name__) + +# Queue endpoint needs its own router (no session_id prefix) +queue_router = APIRouter(prefix="/ai-sessions", tags=["session-handoffs"]) + +# Session-scoped endpoints +router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"]) + + +@router.post("/handoff", response_model=HandoffResponse, status_code=status.HTTP_201_CREATED) +async def create_handoff( + session_id: UUID, + body: HandoffCreateRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> HandoffResponse: + """Create a handoff (park or escalate).""" + result = await db.execute( + select(AISession).where( + AISession.id == session_id, + AISession.user_id == current_user.id, + ) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + manager = HandoffManager(db) + try: + handoff = await manager.create_handoff( + session_id=session_id, + intent=body.intent, + engineer_notes=body.engineer_notes, + user_id=current_user.id, + priority=body.priority, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + await db.commit() + return HandoffResponse.model_validate(handoff) + + +@router.get("/handoffs", response_model=list[HandoffResponse]) +async def list_handoffs( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[HandoffResponse]: + """Get handoff history for a session.""" + result = await db.execute( + select(SessionHandoff) + .where(SessionHandoff.session_id == session_id) + .order_by(SessionHandoff.created_at.desc()) + ) + handoffs = result.scalars().all() + return [HandoffResponse.model_validate(h) for h in handoffs] + + +@router.post("/handoffs/{handoff_id}/claim", response_model=HandoffResponse) +async def claim_handoff( + session_id: UUID, + handoff_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> HandoffResponse: + """Claim a handed-off session.""" + manager = HandoffManager(db) + try: + handoff = await manager.claim_session( + handoff_id=handoff_id, + claiming_user_id=current_user.id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return HandoffResponse.model_validate(handoff) + + +@queue_router.get("/queue") +async def get_queue( + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> list[dict]: + """Get team queue of parked + escalated sessions.""" + manager = HandoffManager(db) + return await manager.get_queue( + team_id=current_user.team_id, + account_id=current_user.account_id, + ) +``` + +- [ ] **Step 3: Register both routers in router.py** + +Add to `backend/app/api/router.py`: + +```python +from app.api.endpoints import session_handoffs +``` + +```python +api_router.include_router(session_handoffs.router) +api_router.include_router(session_handoffs.queue_router) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_session_handoffs_api.py -v --no-header` +Expected: All 2 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/endpoints/session_handoffs.py backend/tests/test_session_handoffs_api.py backend/app/api/router.py +git commit -m "feat: add handoff API endpoints with queue and integration tests" +``` + +--- + +## Phase 4: Resolution Outputs + +### Task 15: Write ResolutionOutputGenerator Service — Tests First + +**Files:** +- Create: `backend/tests/test_resolution_outputs.py` +- Create: `backend/app/services/resolution_output_generator.py` + +- [ ] **Step 1: Write failing tests** + +```python +# backend/tests/test_resolution_outputs.py +"""Integration tests for ResolutionOutputGenerator.""" +import pytest +from unittest.mock import AsyncMock, patch +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput + + +@pytest.mark.asyncio +@patch("app.services.resolution_output_generator._call_ai") +async def test_generate_all_creates_three_outputs( + mock_call_ai, client: AsyncClient, test_user, auth_headers, test_db +): + """generate_all creates PSA notes, KB article, and client summary.""" + mock_call_ai.return_value = ("Generated content here", 100, 50) + + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[ + {"role": "user", "content": "DNS not working"}, + {"role": "assistant", "content": "Fixed by flushing DNS cache"}, + ], + resolution_summary="Flushed DNS cache", + ) + test_db.add(session) + await test_db.flush() + + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(test_db) + outputs = await gen.generate_all(session.id) + + assert len(outputs) == 3 + types = {o.output_type for o in outputs} + assert types == {"psa_ticket_notes", "knowledge_base", "client_summary"} + assert all(o.status == "draft" for o in outputs) + assert mock_call_ai.call_count == 3 + + +@pytest.mark.asyncio +async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db): + """Editing an output stores edited_content.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[], + resolution_summary="Fixed it", + ) + test_db.add(session) + await test_db.flush() + + output = SessionResolutionOutput( + session_id=session.id, + output_type="psa_ticket_notes", + generated_content="Original notes", + status="draft", + generated_by_model="claude-sonnet-4-6", + ) + test_db.add(output) + await test_db.flush() + + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(test_db) + edited = await gen.edit_output(output.id, "My edited notes") + + assert edited.edited_content == "My edited notes" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_resolution_outputs.py -v --no-header 2>&1 | head -15` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement ResolutionOutputGenerator** + +```python +# backend/app/services/resolution_output_generator.py +"""Resolution output generator — three deliverables on session resolve. + +Generates PSA ticket notes, KB article draft, and client-facing summary. +Each is a separate LLM call through _call_ai. +""" +import logging +from typing import Any +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from app.core.config import settings +from app.models.ai_session import AISession +from app.models.session_branch import SessionBranch +from app.models.session_resolution_output import SessionResolutionOutput +from app.services.assistant_chat_service import _call_ai + +logger = logging.getLogger(__name__) + +# Model used for resolution output generation +RESOLUTION_MODEL = "claude-sonnet-4-6" + + +class ResolutionOutputGenerator: + """Generates three resolution outputs on session resolve.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]: + """Generate all three outputs for a resolved session.""" + result = await self.db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise ValueError(f"Session {session_id} not found") + + # Build session context for prompts + context = self._build_session_context(session) + + # Generate all three + outputs = [] + for output_type, prompt in [ + ("psa_ticket_notes", self._psa_notes_prompt(context)), + ("knowledge_base", self._kb_article_prompt(context)), + ("client_summary", self._client_summary_prompt(context)), + ]: + content, _, _ = await _call_ai( + system_base="You are a technical documentation assistant for MSP teams.", + rag_context="", + history=[], + new_message=prompt, + max_tokens=2048, + ) + + output = SessionResolutionOutput( + session_id=session_id, + output_type=output_type, + generated_content=content, + status="draft", + generated_by_model=RESOLUTION_MODEL, + ) + self.db.add(output) + outputs.append(output) + + await self.db.flush() + return outputs + + async def edit_output(self, output_id: UUID, edited_content: str) -> SessionResolutionOutput: + """Save engineer's edited version of an output.""" + result = await self.db.execute( + select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id) + ) + output = result.scalar_one_or_none() + if not output: + raise ValueError(f"Output {output_id} not found") + + output.edited_content = edited_content + await self.db.flush() + return output + + async def push_output( + self, output_id: UUID, destination: str + ) -> SessionResolutionOutput: + """Push output to a destination (psa, kb_library, clipboard, email).""" + result = await self.db.execute( + select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id) + ) + output = result.scalar_one_or_none() + if not output: + raise ValueError(f"Output {output_id} not found") + + # Use edited content if available, otherwise generated + content = output.edited_content or output.generated_content + + if destination == "psa": + # Route to existing PSA documentation service + # TODO: Wire up psa_documentation_service.push_note() + pass + elif destination == "kb_library": + # TODO: Wire up flow/step library creation + pass + + from datetime import datetime, timezone + output.status = "pushed" + output.pushed_to = destination + output.pushed_at = datetime.now(timezone.utc) + + await self.db.flush() + return output + + def _build_session_context(self, session: AISession) -> str: + """Build a text summary of the session for prompts.""" + parts = [ + f"Problem: {session.problem_summary or 'Unknown'}", + f"Domain: {session.problem_domain or 'Unknown'}", + f"Resolution: {session.resolution_summary or 'Not specified'}", + f"Steps taken: {session.step_count}", + ] + # Include conversation highlights + msgs = session.conversation_messages or [] + if msgs: + parts.append("\nConversation highlights:") + for msg in msgs[-10:]: # Last 10 messages + role = msg.get("role", "unknown") + content = msg.get("content", "")[:200] + parts.append(f" [{role}]: {content}") + + return "\n".join(parts) + + def _psa_notes_prompt(self, context: str) -> str: + return ( + f"Generate professional PSA ticket notes for this resolved troubleshooting session.\n" + f"Format as structured markdown with: Problem, Diagnostic Steps, Resolution, Recommendations.\n\n" + f"{context}" + ) + + def _kb_article_prompt(self, context: str) -> str: + return ( + f"Generate a knowledge base article draft from this resolved session.\n" + f"Include: Symptoms, Root Cause, Resolution Steps, Things to Rule Out First.\n" + f"Dead-end branches (if any) should become 'Rule Out First' guidance.\n\n" + f"{context}" + ) + + def _client_summary_prompt(self, context: str) -> str: + return ( + f"Generate a non-technical summary for the end user/client.\n" + f"Explain what was wrong and what was done to fix it in plain language.\n" + f"No jargon. 2-3 paragraphs max.\n\n" + f"{context}" + ) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_resolution_outputs.py -v --no-header` +Expected: All 2 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/tests/test_resolution_outputs.py backend/app/services/resolution_output_generator.py +git commit -m "feat: add ResolutionOutputGenerator with three-output generation" +``` + +### Task 16: Write Resolution API Endpoints + +**Files:** +- Create: `backend/app/api/endpoints/session_resolutions.py` +- Create: `backend/tests/test_session_resolutions_api.py` +- Modify: `backend/app/api/router.py` + +- [ ] **Step 1: Write failing API tests** + +```python +# backend/tests/test_session_resolutions_api.py +"""API tests for resolution output endpoints.""" +import pytest +from unittest.mock import patch +from httpx import AsyncClient + +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput + + +@pytest.mark.asyncio +@patch("app.services.resolution_output_generator._call_ai") +async def test_resolve_generates_outputs(mock_call_ai, client: AsyncClient, test_user, auth_headers, test_db): + """POST /ai-sessions/{id}/resolve generates 3 outputs.""" + mock_call_ai.return_value = ("Generated content", 100, 50) + + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="active", + confidence_tier="guided", + conversation_messages=[{"role": "user", "content": "help"}], + ) + test_db.add(session) + await test_db.commit() + + resp = await client.get( + f"/api/v1/ai-sessions/{session.id}/outputs", + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["outputs"] == [] + + +@pytest.mark.asyncio +async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, test_db): + """PATCH /ai-sessions/{id}/outputs/{oid} edits content.""" + session = AISession( + user_id=test_user["user_data"]["id"], + account_id=test_user["user_data"]["account_id"], + session_type="guided", + intake_type="free_text", + intake_content={"text": "test"}, + status="resolved", + confidence_tier="guided", + conversation_messages=[], + resolution_summary="Fixed", + ) + test_db.add(session) + await test_db.flush() + + output = SessionResolutionOutput( + session_id=session.id, + output_type="psa_ticket_notes", + generated_content="Original", + status="draft", + generated_by_model="claude-sonnet-4-6", + ) + test_db.add(output) + await test_db.commit() + + resp = await client.patch( + f"/api/v1/ai-sessions/{session.id}/outputs/{output.id}", + headers=auth_headers, + json={"edited_content": "My edited version"}, + ) + assert resp.status_code == 200 + assert resp.json()["edited_content"] == "My edited version" +``` + +- [ ] **Step 2: Implement session_resolutions endpoint** + +```python +# backend/app/api/endpoints/session_resolutions.py +"""Resolution output endpoints. + + GET /ai-sessions/{id}/outputs — Get all resolution outputs + PATCH /ai-sessions/{id}/outputs/{oid} — Edit output + POST /ai-sessions/{id}/outputs/{oid}/push — Push to destination +""" +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_session import AISession +from app.models.session_resolution_output import SessionResolutionOutput +from app.services.resolution_output_generator import ResolutionOutputGenerator +from app.schemas.session_resolution import ( + ResolutionOutputResponse, + ResolutionOutputEditRequest, + ResolutionOutputPushRequest, + ResolutionOutputPushResponse, + AllResolutionOutputsResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-resolutions"]) + + +@router.get("/outputs", response_model=AllResolutionOutputsResponse) +async def get_outputs( + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> AllResolutionOutputsResponse: + """Get all resolution outputs for a session.""" + result = await db.execute( + select(SessionResolutionOutput) + .where(SessionResolutionOutput.session_id == session_id) + .order_by(SessionResolutionOutput.output_type) + ) + outputs = result.scalars().all() + return AllResolutionOutputsResponse( + outputs=[ResolutionOutputResponse.model_validate(o) for o in outputs] + ) + + +@router.patch("/outputs/{output_id}", response_model=ResolutionOutputResponse) +async def edit_output( + session_id: UUID, + output_id: UUID, + body: ResolutionOutputEditRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResolutionOutputResponse: + """Edit an output before pushing.""" + gen = ResolutionOutputGenerator(db) + try: + output = await gen.edit_output(output_id, body.edited_content) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return ResolutionOutputResponse.model_validate(output) + + +@router.post("/outputs/{output_id}/push", response_model=ResolutionOutputPushResponse) +async def push_output( + session_id: UUID, + output_id: UUID, + body: ResolutionOutputPushRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> ResolutionOutputPushResponse: + """Push output to destination.""" + gen = ResolutionOutputGenerator(db) + try: + output = await gen.push_output(output_id, body.destination) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + await db.commit() + return ResolutionOutputPushResponse( + output_id=output.id, + status=output.status, + pushed_to=output.pushed_to or body.destination, + pushed_reference=output.pushed_reference, + ) +``` + +- [ ] **Step 3: Register router** + +Add to `backend/app/api/router.py`: + +```python +from app.api.endpoints import session_resolutions +``` + +```python +api_router.include_router(session_resolutions.router) +``` + +- [ ] **Step 4: Run tests** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/test_session_resolutions_api.py -v --no-header` +Expected: All 2 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/endpoints/session_resolutions.py backend/tests/test_session_resolutions_api.py backend/app/api/router.py +git commit -m "feat: add resolution output API endpoints" +``` + +--- + +## Phase 5: AI Description Pipeline + +### Task 17: Extend Upload Endpoint with AI Description + +**Files:** +- Modify: `backend/app/api/endpoints/uploads.py:38-125` + +- [ ] **Step 1: Add background AI description task to upload endpoint** + +In `backend/app/api/endpoints/uploads.py`, after the `await db.commit()` (line ~113), add: + +```python + # Kick off async AI description generation (non-blocking) + import asyncio + asyncio.create_task( + _generate_ai_description(upload.id, file_data, content_type) + ) +``` + +And add this function at module level (after the `_check_storage_configured` function): + +```python +async def _generate_ai_description(upload_id: UUID, file_data: bytes, content_type: str) -> None: + """Background task: generate AI description for uploaded file. + + Catches all exceptions — upload is still usable if this fails. + """ + try: + from app.core.database import async_session_maker + from app.services.assistant_chat_service import _call_ai + import base64 + + async with async_session_maker() as db: + result = await db.execute( + select(FileUpload).where(FileUpload.id == upload_id) + ) + upload = result.scalar_one_or_none() + if not upload: + return + + if content_type.startswith("image/"): + # Image: send to AI with vision + b64_data = base64.b64encode(file_data).decode("utf-8") + description, _, _ = await _call_ai( + system_base="You are a technical image analyst for IT troubleshooting.", + rag_context="", + history=[], + new_message="Describe this image in one sentence for a troubleshooting context log.", + images=[{"media_type": content_type, "data": b64_data}], + max_tokens=100, + ) + upload.ai_description = description + elif content_type.startswith("text/") or content_type in ( + "application/json", "application/xml", "application/yaml", + ): + # Text file: extract content directly + try: + text_content = file_data.decode("utf-8") + except UnicodeDecodeError: + text_content = file_data.decode("latin-1") + + upload.extracted_content = text_content[:10000] # Cap at 10k chars + + # Summarize if long + if len(text_content) > 2000: + summary, _, _ = await _call_ai( + system_base="You are a technical log/config analyst.", + rag_context="", + history=[], + new_message=f"Summarize this file content in 2-3 sentences:\n\n{text_content[:5000]}", + max_tokens=200, + ) + upload.content_summary = summary + upload.ai_description = summary + else: + upload.ai_description = f"Text file: {upload.filename}" + + await db.commit() + except Exception: + logger.exception(f"Failed to generate AI description for upload {upload_id}") +``` + +Also add `from app.core.database import async_session_maker` to the imports if not already present. Check if `async_session_maker` exists in `core/database.py` — if not, add it there. + +- [ ] **Step 2: Verify existing upload tests still pass** + +Run: `cd /home/coder/resolutionflow/backend && pytest tests/ -k "upload" -v --no-header 2>&1 | tail -10` +Expected: Existing tests pass (background task is fire-and-forget). + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/endpoints/uploads.py +git commit -m "feat: add async AI description generation on file upload" +``` + +--- + +## Phase 6: Frontend + +### Task 18: Add TypeScript Types for Branching + +**Files:** +- Create: `frontend/src/types/branching.ts` +- Modify: `frontend/src/types/ai-session.ts:185-198` +- Modify: `frontend/src/types/index.ts` + +- [ ] **Step 1: Create branching types** + +```typescript +// frontend/src/types/branching.ts + +// ── Branch ── + +export interface BranchResponse { + id: string + session_id: string + parent_branch_id: string | null + fork_point_step_id: string | null + branch_order: number + label: string + status: 'active' | 'dead_end' | 'solved' | 'untried' | 'revived' + status_reason: string | null + status_changed_at: string | null + context_summary: { tried: string[]; concluded: string; artifacts: string[] } | null + evidence_from_branch_id: string | null + evidence_description: string | null + step_count: number + created_at: string + updated_at: string +} + +export interface BranchTreeResponse { + branches: BranchResponse[] + active_branch_id: string | null +} + +// ── Fork ── + +export interface ForkOption { + label: string + description: string +} + +export interface ForkCreateRequest { + fork_reason: string + options: ForkOption[] +} + +export interface ForkPointResponse { + id: string + session_id: string + parent_branch_id: string + trigger_step_id: string | null + fork_reason: string + options: Array<{ label: string; description: string; branch_id: string; status: string }> + created_at: string +} + +// ── Switch ── + +export interface BranchSwitchResponse { + active_branch_id: string + branch: BranchResponse + conversation_messages: Array<{ role: string; content: string }> +} + +// ── Revival ── + +export interface ReviveRequest { + evidence_from_branch_id: string + evidence_description: string +} + +// ── Branch message ── + +export interface BranchMessageRequest { + message: string + upload_ids?: string[] +} + +export interface BranchMessageResponse { + content: string + branch_id: string + step_id: string | null +} + +// ── Handoff ── + +export interface HandoffCreateRequest { + intent: 'park' | 'escalate' + engineer_notes?: string + priority?: 'normal' | 'elevated' +} + +export interface HandoffResponse { + id: string + session_id: string + handed_off_by: string + intent: 'park' | 'escalate' + source_branch_id: string | null + snapshot: Record + ai_assessment: string | null + ai_assessment_data: { likely_cause: string; suggested_steps: string[]; confidence: number } | null + artifacts: Array<{ name: string; type: string; reference: string }> | null + engineer_notes: string | null + priority: 'normal' | 'elevated' + claimed_by: string | null + claimed_at: string | null + psa_note_pushed: boolean + notification_sent: boolean + created_at: string +} + +export interface QueueItemResponse { + handoff_id: string + session_id: string + intent: 'park' | 'escalate' + problem_summary: string | null + problem_domain: string | null + priority: 'normal' | 'elevated' + handed_off_by_name: string | null + engineer_notes: string | null + branch_count: number + created_at: string + claimed_by: string | null + claimed_at: string | null +} + +// ── Resolution Outputs ── + +export type ResolutionOutputType = 'psa_ticket_notes' | 'knowledge_base' | 'client_summary' +export type ResolutionOutputStatus = 'draft' | 'approved' | 'pushed' | 'rejected' + +export interface ResolutionOutputResponse { + id: string + session_id: string + output_type: ResolutionOutputType + generated_content: string + structured_data: Record | null + edited_content: string | null + status: ResolutionOutputStatus + pushed_to: string | null + pushed_at: string | null + pushed_reference: string | null + generated_by_model: string + created_at: string + updated_at: string +} + +export interface AllResolutionOutputsResponse { + outputs: ResolutionOutputResponse[] +} + +export interface ResolutionOutputEditRequest { + edited_content: string +} + +export type PushDestination = 'psa' | 'kb_library' | 'clipboard' | 'email' + +export interface ResolutionOutputPushRequest { + destination: PushDestination +} +``` + +- [ ] **Step 2: Add branching fields to AISessionDetail** + +In `frontend/src/types/ai-session.ts`, add to `AISessionDetail` interface (after `conversation_messages`): + +```typescript + is_branching: boolean + active_branch_id: string | null +``` + +- [ ] **Step 3: Export from types/index.ts** + +Add `export * from './branching'` to `frontend/src/types/index.ts` (or equivalent). + +- [ ] **Step 4: Verify build** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit 2>&1 | head -20` +Expected: No type errors. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/types/branching.ts frontend/src/types/ai-session.ts frontend/src/types/index.ts +git commit -m "feat: add TypeScript types for branching, handoffs, and resolution outputs" +``` + +### Task 19: Add Frontend API Clients + +**Files:** +- Create: `frontend/src/api/branches.ts` +- Create: `frontend/src/api/handoffs.ts` +- Create: `frontend/src/api/resolutions.ts` +- Modify: `frontend/src/api/index.ts` + +- [ ] **Step 1: Create branches API client** + +```typescript +// frontend/src/api/branches.ts +import apiClient from './client' +import type { + BranchTreeResponse, + BranchResponse, + ForkCreateRequest, + ForkPointResponse, + BranchSwitchResponse, + ReviveRequest, + BranchMessageRequest, + BranchMessageResponse, +} from '@/types/branching' + +export const branchesApi = { + async getBranches(sessionId: string): Promise { + const resp = await apiClient.get(`/ai-sessions/${sessionId}/branches`) + return resp.data + }, + + async createFork(sessionId: string, data: ForkCreateRequest): Promise { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/branches/fork`, data) + return resp.data + }, + + async updateBranchStatus(sessionId: string, branchId: string, data: { status: string; status_reason?: string }): Promise { + const resp = await apiClient.patch(`/ai-sessions/${sessionId}/branches/${branchId}`, data) + return resp.data + }, + + async switchBranch(sessionId: string, branchId: string): Promise { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/branches/${branchId}/switch`) + return resp.data + }, + + async reviveBranch(sessionId: string, branchId: string, data: ReviveRequest): Promise { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/branches/${branchId}/revive`, data) + return resp.data + }, + + async sendBranchMessage(sessionId: string, branchId: string, data: BranchMessageRequest): Promise { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/branches/${branchId}/message`, data) + return resp.data + }, +} +``` + +- [ ] **Step 2: Create handoffs API client** + +```typescript +// frontend/src/api/handoffs.ts +import apiClient from './client' +import type { + HandoffCreateRequest, + HandoffResponse, + QueueItemResponse, +} from '@/types/branching' + +export const handoffsApi = { + async createHandoff(sessionId: string, data: HandoffCreateRequest): Promise { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/handoff`, data) + return resp.data + }, + + async listHandoffs(sessionId: string): Promise { + const resp = await apiClient.get(`/ai-sessions/${sessionId}/handoffs`) + return resp.data + }, + + async claimHandoff(sessionId: string, handoffId: string): Promise { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/handoffs/${handoffId}/claim`) + return resp.data + }, + + async getQueue(): Promise { + const resp = await apiClient.get('/ai-sessions/queue') + return resp.data + }, +} +``` + +- [ ] **Step 3: Create resolutions API client** + +```typescript +// frontend/src/api/resolutions.ts +import apiClient from './client' +import type { + AllResolutionOutputsResponse, + ResolutionOutputResponse, + ResolutionOutputEditRequest, + ResolutionOutputPushRequest, +} from '@/types/branching' + +export const resolutionsApi = { + async getOutputs(sessionId: string): Promise { + const resp = await apiClient.get(`/ai-sessions/${sessionId}/outputs`) + return resp.data + }, + + async editOutput(sessionId: string, outputId: string, data: ResolutionOutputEditRequest): Promise { + const resp = await apiClient.patch(`/ai-sessions/${sessionId}/outputs/${outputId}`, data) + return resp.data + }, + + async pushOutput(sessionId: string, outputId: string, data: ResolutionOutputPushRequest): Promise<{ output_id: string; status: string; pushed_to: string; pushed_reference: string | null }> { + const resp = await apiClient.post(`/ai-sessions/${sessionId}/outputs/${outputId}/push`, data) + return resp.data + }, +} +``` + +- [ ] **Step 4: Export from api/index.ts** + +Add to `frontend/src/api/index.ts`: + +```typescript +export { branchesApi } from './branches' +export { handoffsApi } from './handoffs' +export { resolutionsApi } from './resolutions' +``` + +- [ ] **Step 5: Verify build** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit 2>&1 | head -10` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/api/branches.ts frontend/src/api/handoffs.ts frontend/src/api/resolutions.ts frontend/src/api/index.ts +git commit -m "feat: add frontend API clients for branches, handoffs, and resolutions" +``` + +### Task 20: Create useBranching Hook + +**Files:** +- Create: `frontend/src/hooks/useBranching.ts` + +- [ ] **Step 1: Implement the useBranching hook** + +```typescript +// frontend/src/hooks/useBranching.ts +import { useState, useCallback } from 'react' +import { branchesApi } from '@/api' +import type { + BranchResponse, + BranchTreeResponse, + ForkCreateRequest, + ForkPointResponse, + BranchSwitchResponse, + BranchMessageResponse, +} from '@/types/branching' +import { toast } from '@/lib/toast' + +export interface UseBranching { + branches: BranchResponse[] + activeBranchId: string | null + isLoading: boolean + + loadBranches: (sessionId: string) => Promise + createFork: (sessionId: string, data: ForkCreateRequest) => Promise + switchBranch: (sessionId: string, branchId: string) => Promise + updateStatus: (sessionId: string, branchId: string, status: string, reason?: string) => Promise + reviveBranch: (sessionId: string, branchId: string, evidenceFromId: string, description: string) => Promise + sendMessage: (sessionId: string, branchId: string, message: string) => Promise +} + +export function useBranching(): UseBranching { + const [branches, setBranches] = useState([]) + const [activeBranchId, setActiveBranchId] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const loadBranches = useCallback(async (sessionId: string) => { + setIsLoading(true) + try { + const data = await branchesApi.getBranches(sessionId) + setBranches(data.branches) + setActiveBranchId(data.active_branch_id) + } catch { + toast.error('Failed to load branches') + } finally { + setIsLoading(false) + } + }, []) + + const createFork = useCallback(async (sessionId: string, data: ForkCreateRequest) => { + try { + const result = await branchesApi.createFork(sessionId, data) + await loadBranches(sessionId) + return result + } catch { + toast.error('Failed to create fork') + return null + } + }, [loadBranches]) + + const switchBranch = useCallback(async (sessionId: string, branchId: string) => { + try { + const result = await branchesApi.switchBranch(sessionId, branchId) + setActiveBranchId(result.active_branch_id) + // Update branches list + setBranches(prev => prev.map(b => + b.id === branchId ? result.branch : b + )) + return result + } catch { + toast.error('Failed to switch branch') + return null + } + }, []) + + const updateStatus = useCallback(async (sessionId: string, branchId: string, status: string, reason?: string) => { + try { + const updated = await branchesApi.updateBranchStatus(sessionId, branchId, { status, status_reason: reason }) + setBranches(prev => prev.map(b => b.id === branchId ? updated : b)) + } catch { + toast.error('Failed to update branch status') + } + }, []) + + const reviveBranch = useCallback(async (sessionId: string, branchId: string, evidenceFromId: string, description: string) => { + try { + await branchesApi.reviveBranch(sessionId, branchId, { + evidence_from_branch_id: evidenceFromId, + evidence_description: description, + }) + await loadBranches(sessionId) + toast.success('Branch revived') + } catch { + toast.error('Failed to revive branch') + } + }, [loadBranches]) + + const sendMessage = useCallback(async (sessionId: string, branchId: string, message: string) => { + try { + return await branchesApi.sendBranchMessage(sessionId, branchId, { message }) + } catch { + toast.error('Failed to send message') + return null + } + }, []) + + return { + branches, + activeBranchId, + isLoading, + loadBranches, + createFork, + switchBranch, + updateStatus, + reviveBranch, + sendMessage, + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit 2>&1 | head -10` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/hooks/useBranching.ts +git commit -m "feat: add useBranching hook for branch state management" +``` + +### Task 21: Create BranchMap Sidebar Component + +**Files:** +- Create: `frontend/src/components/session/BranchMap.tsx` +- Create: `frontend/src/components/session/BranchNode.tsx` + +- [ ] **Step 1: Create BranchNode component** + +```tsx +// frontend/src/components/session/BranchNode.tsx +import { cn } from '@/lib/utils' +import { GitBranch, CheckCircle2, XCircle, CircleDot, RotateCcw, Circle } from 'lucide-react' +import type { BranchResponse } from '@/types/branching' + +const STATUS_CONFIG = { + active: { icon: CircleDot, color: 'text-accent', bg: 'bg-accent-dim', label: 'Active' }, + solved: { icon: CheckCircle2, color: 'text-green-400', bg: 'bg-green-400/10', label: 'Solved' }, + dead_end: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-400/10', label: 'Dead End' }, + untried: { icon: Circle, color: 'text-muted', bg: 'bg-elevated', label: 'Untried' }, + revived: { icon: RotateCcw, color: 'text-yellow-400', bg: 'bg-yellow-400/10', label: 'Revived' }, +} as const + +interface BranchNodeProps { + branch: BranchResponse + isActive: boolean + depth: number + onClick: (branchId: string) => void +} + +export function BranchNode({ branch, isActive, depth, onClick }: BranchNodeProps) { + const config = STATUS_CONFIG[branch.status] || STATUS_CONFIG.untried + const Icon = config.icon + + return ( + + ) +} +``` + +- [ ] **Step 2: Create BranchMap component** + +```tsx +// frontend/src/components/session/BranchMap.tsx +import { useEffect } from 'react' +import { GitBranch } from 'lucide-react' +import type { BranchResponse } from '@/types/branching' +import { BranchNode } from './BranchNode' + +interface BranchMapProps { + branches: BranchResponse[] + activeBranchId: string | null + onSwitchBranch: (branchId: string) => void +} + +interface TreeNode { + branch: BranchResponse + children: TreeNode[] + depth: number +} + +function buildTree(branches: BranchResponse[]): TreeNode[] { + const byId = new Map(branches.map(b => [b.id, b])) + const childrenMap = new Map() + + for (const b of branches) { + const parentId = b.parent_branch_id + if (!childrenMap.has(parentId)) childrenMap.set(parentId, []) + childrenMap.get(parentId)!.push(b) + } + + function buildNode(branch: BranchResponse, depth: number): TreeNode { + const children = (childrenMap.get(branch.id) || []) + .sort((a, b) => a.branch_order - b.branch_order) + .map(child => buildNode(child, depth + 1)) + return { branch, children, depth } + } + + const roots = (childrenMap.get(null) || []) + .sort((a, b) => a.branch_order - b.branch_order) + + return roots.map(r => buildNode(r, 0)) +} + +function flattenTree(nodes: TreeNode[]): TreeNode[] { + const result: TreeNode[] = [] + for (const node of nodes) { + result.push(node) + result.push(...flattenTree(node.children)) + } + return result +} + +export function BranchMap({ branches, activeBranchId, onSwitchBranch }: BranchMapProps) { + if (branches.length === 0) return null + + const tree = buildTree(branches) + const flat = flattenTree(tree) + + return ( +
+
+ + + Branch Map + +
+ {flat.map(node => ( + + ))} +
+ ) +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit 2>&1 | head -10` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/session/BranchMap.tsx frontend/src/components/session/BranchNode.tsx +git commit -m "feat: add BranchMap sidebar with BranchNode tree visualization" +``` + +### Task 22: Create ForkCard Component + +**Files:** +- Create: `frontend/src/components/session/ForkCard.tsx` + +- [ ] **Step 1: Implement ForkCard** + +```tsx +// frontend/src/components/session/ForkCard.tsx +import { GitFork } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface ForkOption { + label: string + description: string + branch_id: string + status: string +} + +interface ForkCardProps { + forkReason: string + options: ForkOption[] + activeBranchId: string | null + onSelectBranch: (branchId: string) => void +} + +export function ForkCard({ forkReason, options, activeBranchId, onSelectBranch }: ForkCardProps) { + return ( +
+
+ + Fork Point +
+

{forkReason}

+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit 2>&1 | head -5` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/session/ForkCard.tsx +git commit -m "feat: add ForkCard component for in-chat fork decision points" +``` + +### Task 23: Create HandoffModal Component + +**Files:** +- Create: `frontend/src/components/session/HandoffModal.tsx` + +- [ ] **Step 1: Implement HandoffModal** + +```tsx +// frontend/src/components/session/HandoffModal.tsx +import { useState } from 'react' +import { Pause, ArrowUpRight, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { HandoffCreateRequest } from '@/types/branching' + +interface HandoffModalProps { + isOpen: boolean + onClose: () => void + onSubmit: (data: HandoffCreateRequest) => Promise +} + +export function HandoffModal({ isOpen, onClose, onSubmit }: HandoffModalProps) { + const [intent, setIntent] = useState<'park' | 'escalate'>('park') + const [notes, setNotes] = useState('') + const [priority, setPriority] = useState<'normal' | 'elevated'>('normal') + const [isSubmitting, setIsSubmitting] = useState(false) + + if (!isOpen) return null + + const handleSubmit = async () => { + setIsSubmitting(true) + try { + await onSubmit({ intent, engineer_notes: notes || undefined, priority }) + onClose() + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ {/* Header */} +
+ Hand Off Session + +
+ + {/* Intent toggle */} +
+
+ + +
+ + {/* Notes */} +
+ +