Compare commits
122 Commits
f3c3ee5b57
...
fix/e2e-te
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c4e0c99e | |||
| f27f671fe6 | |||
| d6218f2e07 | |||
| 920a246d77 | |||
| b7f8e70be2 | |||
| 857d73e3d0 | |||
| 406ee0ef97 | |||
| 32fae2c693 | |||
| a45915fbbc | |||
| 06593a40d9 | |||
| 9737d90f1b | |||
| 1c904373f8 | |||
| 16060d2235 | |||
| 9330ce4782 | |||
| d68131a865 | |||
| 875bd924a9 | |||
| 49c6c8fd00 | |||
| a77e8ea578 | |||
| 90252bc98f | |||
| 036431aef8 | |||
| b3be1e0749 | |||
| b3506b5e73 | |||
| b14a16a1ab | |||
| 9c8ba296a8 | |||
| bee8690056 | |||
| e110fedfe4 | |||
| dab740ddf7 | |||
| 24972e8444 | |||
| d386d11af2 | |||
| 65a831bf9a | |||
| faf1d8dd12 | |||
| 0386fa1fd5 | |||
| 82db1c78e4 | |||
| f930787200 | |||
| 5bcb7aa7c3 | |||
| 04fbfe3b8f | |||
| f92cbefed9 | |||
| c9306e40c9 | |||
| 1c855563ee | |||
| d4fae87236 | |||
| f2fce27f0d | |||
| 93c974466a | |||
| 8012668975 | |||
| 563bb1aa6f | |||
| 1d2d548fc8 | |||
| 3ee0101c6d | |||
| 861d082ff7 | |||
| 75b59123e6 | |||
| fcd224429c | |||
| 196c003876 | |||
| f2b9476edb | |||
| 70c5da0c75 | |||
| de2bef3175 | |||
| 362c7b1d79 | |||
| ec104dc8de | |||
| a47ce07326 | |||
| 2a54127a54 | |||
| 8582d24236 | |||
| bdb238a274 | |||
| 075b0fc1d8 | |||
| 217747f46e | |||
| 7fa1d6a32f | |||
| ac67e48500 | |||
| cdd29b460e | |||
| 2cde6673b0 | |||
| c0112f8bee | |||
| 8988dbc885 | |||
| 4a8e3ae954 | |||
| cdd8bb05cc | |||
| 8879f96fbf | |||
| 8a242f5db9 | |||
| 4aaf57adb5 | |||
| ddae171a37 | |||
| d0ebdef9e8 | |||
| 50215b9110 | |||
| ce7c8ac3d5 | |||
| fa61376303 | |||
| 8fd2c1bac6 | |||
| 7ccf4c602b | |||
| 66e592096c | |||
| 625dba7548 | |||
| 19cfd71995 | |||
| 3b55697c77 | |||
| 851966966d | |||
| 66968e4c59 | |||
| b0622f5511 | |||
| 995a0c1d2e | |||
| f6a24ea4e1 | |||
| 04ff2ea301 | |||
| 60851b400a | |||
| bea34229d6 | |||
| 294b309faa | |||
| fb7690485b | |||
| 6044d5a88b | |||
| 00cd8b7c55 | |||
| fded959b5e | |||
| 5f5b9e5b23 | |||
| b2ee1a2150 | |||
| 08909aa884 | |||
| 070d2383bc | |||
| d7b1fe6645 | |||
| a3f8bb3427 | |||
| f050afc2f7 | |||
| 849e1c16e2 | |||
| 5310cd3fff | |||
| d2689afa53 | |||
| 9d88c8456c | |||
| 506aac609d | |||
| 7fa81f69a6 | |||
| 6e0188d0b4 | |||
| 24ab1908a6 | |||
| e2cdfac1c3 | |||
| a5e9615666 | |||
| 66cca70588 | |||
| e714088a2b | |||
| ff0ec143e2 | |||
| 8d964e64e4 | |||
| 44634b1145 | |||
| 001438008b | |||
| c8b68ad26d | |||
| 2b3d52ad77 | |||
| 52b369680b |
33
.ai/CURRENT_TASK.md
Normal file
33
.ai/CURRENT_TASK.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# CURRENT_TASK.md
|
||||||
|
|
||||||
|
**Task:** none — replace this file when starting the next real task.
|
||||||
|
|
||||||
|
**Status:** not-started
|
||||||
|
|
||||||
|
**Definition of Done:** n/a
|
||||||
|
|
||||||
|
**Assumptions:** n/a
|
||||||
|
|
||||||
|
**Out of scope:** n/a
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- When you start a real task, replace the block above with:
|
||||||
|
|
||||||
|
**Task:** One-sentence goal.
|
||||||
|
|
||||||
|
**Status:** not-started | in-progress | blocked | ready-for-review | complete
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
- [ ] Testable criterion 1
|
||||||
|
- [ ] Testable criterion 2
|
||||||
|
- [ ] Tests added or updated
|
||||||
|
- [ ] `npm run build` passes (frontend) / `pytest` passes (backend)
|
||||||
|
|
||||||
|
**Assumptions:**
|
||||||
|
- What we're treating as given
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- What this task explicitly does NOT cover
|
||||||
|
|
||||||
|
-->
|
||||||
31
.ai/DECISIONS.md
Normal file
31
.ai/DECISIONS.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# DECISIONS.md
|
||||||
|
|
||||||
|
> Append-only architectural decision log. Newest entries at the top.
|
||||||
|
> Entry format:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> ## YYYY-MM-DD — <short title>
|
||||||
|
> **Context:** why this came up
|
||||||
|
> **Decision:** what we chose
|
||||||
|
> **Rejected:** what we didn't choose and why
|
||||||
|
> **Consequences:** what this means going forward
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-24 — Adopt dual-agent handoff system (`.ai/` + `CLAUDE.md` + `AGENTS.md`)
|
||||||
|
|
||||||
|
**Context:** Claude Code hits session and weekly usage limits. Work stalls when the primary agent is locked out. Needed a structured way for OpenAI Codex to resume where Claude left off without losing architectural truth or drifting across sessions.
|
||||||
|
|
||||||
|
**Decision:** Split the old CLAUDE.md into `.ai/PROJECT_CONTEXT.md` (stable repo truth), agent-specific root files (`CLAUDE.md`, `AGENTS.md`) with a shared protocol block, and a small handoff toolkit (`CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`). Previous CLAUDE.md snapshotted in commit `e110fed` before the migration.
|
||||||
|
|
||||||
|
**Rejected:**
|
||||||
|
- Single symlinked CLAUDE.md/AGENTS.md — diverges silently, hides agent-specific tooling differences.
|
||||||
|
- Putting GitNexus/gstack content in AGENTS.md — Codex doesn't have those tools; would mislead the resume agent.
|
||||||
|
- Keeping the old CLAUDE.md as-is and adding AGENTS.md alongside it — duplicated truth, drift guaranteed.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- First read for either agent: `.ai/PROJECT_CONTEXT.md` + `.ai/CURRENT_TASK.md` + `.ai/HANDOFF.md`.
|
||||||
|
- Architectural changes in the repo require updating PROJECT_CONTEXT.md, not the root agent files.
|
||||||
|
- Git trailers differ per agent (`Claude Opus 4.7` vs `Codex`) — preserved in each root file.
|
||||||
|
- Legacy `SESSION-HANDOFF.md` deleted in the same commit; superseded by `.ai/HANDOFF.md`.
|
||||||
35
.ai/HANDOFF.md
Normal file
35
.ai/HANDOFF.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!-- Keep under ~2K tokens. Old handoffs live in SESSION_LOG.md. Do not let this file accumulate history. -->
|
||||||
|
|
||||||
|
# HANDOFF.md
|
||||||
|
|
||||||
|
**Last updated:** 2026-04-24 (America/New_York)
|
||||||
|
|
||||||
|
**Active task:** None — see [CURRENT_TASK.md](CURRENT_TASK.md). Replace it when picking up the next real task.
|
||||||
|
|
||||||
|
**Branch:** `feat/flowpilot-migration` — a long-running FlowPilot Phase 9 feature branch. The recent AI-handoff migration commits ride on this branch (not on their own branch); they'll merge to `main` whenever Phase 9 does.
|
||||||
|
|
||||||
|
**Branch state:** 3 commits ahead of `origin/feat/flowpilot-migration`:
|
||||||
|
|
||||||
|
- `b3be1e0 chore: ignore .remember/ skill runtime state`
|
||||||
|
- `b3506b5 docs(pilot): phase 9 review issues`
|
||||||
|
- `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`
|
||||||
|
|
||||||
|
Earlier in this session (already pushed to origin):
|
||||||
|
|
||||||
|
- `9c8ba29 fix(ai): correct stale role-hierarchy and file-listing claims`
|
||||||
|
- `bee8690 chore(ai): migrate to dual-agent handoff system`
|
||||||
|
- `e110fed chore: snapshot CLAUDE.md before ai-handoff migration` (tag: `pre-ai-handoff`)
|
||||||
|
|
||||||
|
**Where I left off:**
|
||||||
|
- File: n/a — nothing mid-edit.
|
||||||
|
- Next intended action: push the 3 unpushed commits when ready (`git push`), then start the next real task (replace `CURRENT_TASK.md`, update this file).
|
||||||
|
|
||||||
|
**Uncommitted state:**
|
||||||
|
- Working tree is clean.
|
||||||
|
|
||||||
|
**Immediate next steps:**
|
||||||
|
1. `git push` to publish the 3 local commits (cleanup batch).
|
||||||
|
2. When starting the next real feature task: replace `CURRENT_TASK.md` with actual goal/DoD, rewrite this file's resume section.
|
||||||
|
|
||||||
|
**Open questions / blockers:**
|
||||||
|
- None. The dual-agent handoff system is live and has survived one Codex review round (see DECISIONS.md 2026-04-24 entry; corrections in `9c8ba29`).
|
||||||
254
.ai/PROJECT_CONTEXT.md
Normal file
254
.ai/PROJECT_CONTEXT.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# PROJECT_CONTEXT.md — ResolutionFlow
|
||||||
|
|
||||||
|
> SaaS troubleshooting platform for MSPs. Stable architectural truth. Updated only when the repo's shape changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product & naming
|
||||||
|
|
||||||
|
Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
|
||||||
|
|
||||||
|
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SaaS shape
|
||||||
|
|
||||||
|
Multi-tenant by account. Primary role hierarchy: `super_admin` > `owner` > `engineer` > `viewer` — driven by `is_super_admin` + `account_role`. Never `role=='admin'` — use `is_super_admin`. Separate team-scoped admin gate exists orthogonally to the role hierarchy: `is_team_admin=True` + valid `team_id`, enforced by `require_team_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`, `require_account_owner`, `require_team_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See [CURRENT-STATE.md](../CURRENT-STATE.md) for live status, [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) for phases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||||
|
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||||
|
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
resolutionflow/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # FastAPI entry
|
||||||
|
│ │ ├── api/endpoints/ # 50+ routers registered in api/router.py — auth/admin, trees/sessions, AI/chat, scripts, integrations, uploads, accounts, FlowPilot, etc.
|
||||||
|
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
|
||||||
|
│ │ ├── api/router.py # registration
|
||||||
|
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||||
|
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
|
||||||
|
│ │ ├── schemas/ # Pydantic
|
||||||
|
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, exceptions, registry, ticket_context, types)
|
||||||
|
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
|
||||||
|
│ │ └── services/knowledge_gap_service.py
|
||||||
|
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
|
||||||
|
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
|
||||||
|
│ └── tests/ # pytest integration
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # Axios client + endpoint modules
|
||||||
|
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
||||||
|
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||||
|
│ │ └── types/
|
||||||
|
│ └── (Tailwind v4 CSS-only config in src/index.css)
|
||||||
|
├── docs/plans/archive/ # pre-March 2026 plans
|
||||||
|
├── docs/connectwise/ # CW API reference + best-practices guides
|
||||||
|
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
|
||||||
|
├── .ai/ # dual-agent handoff system (see .ai/README.md)
|
||||||
|
├── CLAUDE.md · AGENTS.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev commands
|
||||||
|
|
||||||
|
Full setup in [DEV-ENV.md](../DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d # start stack
|
||||||
|
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
|
||||||
|
cd frontend && npm run dev
|
||||||
|
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
|
||||||
|
cd backend && alembic upgrade head # migrate
|
||||||
|
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
|
||||||
|
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
|
||||||
|
cd frontend && npm run build # stricter than tsc --noEmit — final check
|
||||||
|
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
|
||||||
|
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||||
|
python -m scripts.seed_trees # seed (from backend/)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URLs & test users
|
||||||
|
|
||||||
|
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
|
||||||
|
|
||||||
|
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment (Railway)
|
||||||
|
|
||||||
|
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
|
||||||
|
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
|
||||||
|
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
|
||||||
|
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
|
||||||
|
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
|
||||||
|
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ConnectWise PSA
|
||||||
|
|
||||||
|
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
|
||||||
|
|
||||||
|
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||||
|
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
|
||||||
|
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
|
||||||
|
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding standards
|
||||||
|
|
||||||
|
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
|
||||||
|
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
|
||||||
|
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Commit format: `type: description` (feat/fix/refactor/docs/test/chore). Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly. (Agent-specific `Co-Authored-By` trailers live in CLAUDE.md / AGENTS.md.)
|
||||||
|
|
||||||
|
**After shipping:** update [CURRENT-STATE.md](../CURRENT-STATE.md) + [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md), `gh issue close #N` for resolved issues, add lessons only for non-obvious traps (otherwise let the code speak).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
- **New endpoint:** `endpoints/` → `router.py` → `schemas/` → tests → frontend API client.
|
||||||
|
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
|
||||||
|
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
|
||||||
|
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
|
||||||
|
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
|
||||||
|
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
|
||||||
|
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx` — `AccountLayout` has NO sidebar nav.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
|
||||||
|
**Source of truth: [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md).** Read before any visual change.
|
||||||
|
|
||||||
|
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
|
||||||
|
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
|
||||||
|
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
|
||||||
|
- Text: `text-heading` → `text-primary` → `text-muted-foreground` → `text-muted`.
|
||||||
|
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
|
||||||
|
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
|
||||||
|
- Mockups: `docs/mockups/` (HTML).
|
||||||
|
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend patterns
|
||||||
|
|
||||||
|
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
|
||||||
|
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
|
||||||
|
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
|
||||||
|
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
|
||||||
|
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
|
||||||
|
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
|
||||||
|
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
|
||||||
|
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage` → `/ai-sessions/{id}/chat` → `unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
|
||||||
|
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
|
||||||
|
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
|
||||||
|
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
|
||||||
|
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
|
||||||
|
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
|
||||||
|
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
|
||||||
|
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
|
||||||
|
- **Node field priority** (copilot, summaries): `title` → `question` → `description` → `content` → `label`.
|
||||||
|
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical lessons
|
||||||
|
|
||||||
|
> Lessons 1-40 archived to [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
|
||||||
|
|
||||||
|
### Backend / data
|
||||||
|
|
||||||
|
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
|
||||||
|
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
|
||||||
|
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
|
||||||
|
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
|
||||||
|
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
|
||||||
|
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
|
||||||
|
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
|
||||||
|
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
|
||||||
|
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
|
||||||
|
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
|
||||||
|
|
||||||
|
### AI / FlowPilot
|
||||||
|
|
||||||
|
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
|
||||||
|
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
|
||||||
|
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
|
||||||
|
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
|
||||||
|
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
|
||||||
|
|
||||||
|
### Frontend / UI
|
||||||
|
|
||||||
|
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
|
||||||
|
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
|
||||||
|
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
|
||||||
|
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
|
||||||
|
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
|
||||||
|
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
|
||||||
|
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
|
||||||
|
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
|
||||||
|
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
|
||||||
|
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
|
||||||
|
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
|
||||||
|
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
|
||||||
|
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
|
||||||
|
|
||||||
|
### Env / infra
|
||||||
|
|
||||||
|
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||||
|
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||||
|
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
|
||||||
|
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
|
||||||
|
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||||
|
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
|
||||||
|
- **Dev env** — see [DEV-ENV.md](../DEV-ENV.md) for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| Detailed status | [CURRENT-STATE.md](../CURRENT-STATE.md) |
|
||||||
|
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) |
|
||||||
|
| Design system | [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md) |
|
||||||
|
| Dev env | [DEV-ENV.md](../DEV-ENV.md) |
|
||||||
|
| Archived lessons | [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) |
|
||||||
|
| ConnectWise API | `docs/connectwise/` |
|
||||||
|
| GitHub issues | `gh issue list --state open` |
|
||||||
|
| Local API docs | <http://localhost:8000/api/docs> |
|
||||||
|
| Handoff system | [.ai/README.md](README.md) |
|
||||||
42
.ai/README.md
Normal file
42
.ai/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# .ai/ — dual-agent handoff system
|
||||||
|
|
||||||
|
ResolutionFlow uses two coding agents: **Claude Code** (primary) and **OpenAI Codex** (resume when Claude hits session or weekly limits). This directory holds the shared state that lets either agent start a session with full context.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Holds | Written when | Read when |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) | Stable repo truth: stack, structure, SaaS shape, ConnectWise, coding standards, frontend patterns, critical lessons | Only when the repo's shape changes | Every session start |
|
||||||
|
| [CURRENT_TASK.md](CURRENT_TASK.md) | The single active task: goal, DoD, assumptions, out-of-scope | On task start; status updates during work | Every session start |
|
||||||
|
| [HANDOFF.md](HANDOFF.md) | Exact resume point: branch, where you left off, next steps, blockers | On session end / context-window limit | Every session start (most important) |
|
||||||
|
| [TODO.md](TODO.md) | Backlog of work NOT currently active | When deferring or queueing work | Only when `CURRENT_TASK.md` is `complete` |
|
||||||
|
| [DECISIONS.md](DECISIONS.md) | Append-only architectural decision log | When an architectural choice is made | Skim top entries each session |
|
||||||
|
| [SESSION_LOG.md](SESSION_LOG.md) | Append-only chronological history | On session end | Only when broader context is needed |
|
||||||
|
|
||||||
|
Agent-specific tooling lives at the repo root:
|
||||||
|
- [../CLAUDE.md](../CLAUDE.md) — Claude Code's tooling (GitNexus, gstack slash commands, Claude trailer)
|
||||||
|
- [../AGENTS.md](../AGENTS.md) — OpenAI Codex's tooling (grep/rg fallbacks, Codex trailer)
|
||||||
|
|
||||||
|
Both root files contain an **identical shared-protocol block**. If you edit one, edit the other.
|
||||||
|
|
||||||
|
## The handoff ritual
|
||||||
|
|
||||||
|
At session end (limit hit, task complete, or user stop): update `HANDOFF.md` to reflect the new resume point, update `CURRENT_TASK.md` status if it changed, append to `DECISIONS.md` if you made an architectural call, append a session entry to `SESSION_LOG.md`, and WIP-commit any dirty working tree with `wip(handoff): <one-line>` unless told otherwise. Don't push.
|
||||||
|
|
||||||
|
## How to invoke a resume
|
||||||
|
|
||||||
|
Tell the agent:
|
||||||
|
|
||||||
|
> Read CLAUDE.md (or AGENTS.md) and follow its instructions.
|
||||||
|
|
||||||
|
The agent will read its root file, which directs it to `.ai/PROJECT_CONTEXT.md`, `.ai/CURRENT_TASK.md`, and `.ai/HANDOFF.md` before doing anything else.
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
The previous monolithic CLAUDE.md is recoverable via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git show pre-ai-handoff:CLAUDE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
(Tag `pre-ai-handoff` on commit `e110fed` — the snapshot taken before this migration.)
|
||||||
23
.ai/SESSION_LOG.md
Normal file
23
.ai/SESSION_LOG.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# SESSION_LOG.md
|
||||||
|
|
||||||
|
> Append-only chronological record. Newest entries at the top. Skim when broader context is needed.
|
||||||
|
> Entry format:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> ## YYYY-MM-DD HH:MM <timezone> — <agent> — <one-line summary>
|
||||||
|
> - What was accomplished
|
||||||
|
> - What was left for next session
|
||||||
|
> - Files touched
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
|
||||||
|
|
||||||
|
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
|
||||||
|
- Seeded `CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`.
|
||||||
|
- Deleted legacy `SESSION-HANDOFF.md` (superseded).
|
||||||
|
- Left for next session: first real feature task should replace the seed `CURRENT_TASK.md` and update `HANDOFF.md` with real resume state.
|
||||||
|
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
||||||
|
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
||||||
|
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
||||||
12
.ai/TODO.md
Normal file
12
.ai/TODO.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# TODO.md
|
||||||
|
|
||||||
|
> Backlog of work NOT currently active. Read only when `CURRENT_TASK.md` status is `complete`.
|
||||||
|
> Format: `- [ ] short description — optional link to issue/PR`
|
||||||
|
|
||||||
|
## Up next
|
||||||
|
|
||||||
|
- [ ] No queued backlog yet.
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
- [ ] No queued backlog yet.
|
||||||
20
.claude/hooks/check-gstack.sh
Executable file
20
.claude/hooks/check-gstack.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Block skill usage when gstack is not installed globally.
|
||||||
|
|
||||||
|
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
BLOCKED: gstack is not installed globally.
|
||||||
|
|
||||||
|
gstack is required for AI-assisted work in this repo.
|
||||||
|
|
||||||
|
Install it:
|
||||||
|
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
||||||
|
cd ~/.claude/skills/gstack && ./setup --team
|
||||||
|
|
||||||
|
Then restart your AI coding tool.
|
||||||
|
MSG
|
||||||
|
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '{}'
|
||||||
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Skill",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -207,7 +207,11 @@ marimo/_lsp/
|
|||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
# Claude Code (local config, agents, settings)
|
# Claude Code (local config, agents, settings)
|
||||||
.claude/
|
.claude/*
|
||||||
|
!.claude/settings.json
|
||||||
|
!.claude/hooks/
|
||||||
|
.claude/hooks/*
|
||||||
|
!.claude/hooks/check-gstack.sh
|
||||||
.agents/
|
.agents/
|
||||||
|
|
||||||
# Database dumps
|
# Database dumps
|
||||||
@@ -238,3 +242,6 @@ package-lock.json
|
|||||||
# graphify knowledge graph outputs
|
# graphify knowledge graph outputs
|
||||||
graphify-out/
|
graphify-out/
|
||||||
.graphify_python
|
.graphify_python
|
||||||
|
|
||||||
|
# remember skill runtime state (hook logs, PIDs)
|
||||||
|
.remember/
|
||||||
|
|||||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# AGENTS.md — ResolutionFlow
|
||||||
|
|
||||||
|
You are OpenAI Codex, the resume agent for ResolutionFlow. Claude Code is the primary coding agent; you step in when Claude hits session or weekly limits.
|
||||||
|
|
||||||
|
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
|
||||||
|
|
||||||
|
> The protocol section below is byte-identical to the shared block in CLAUDE.md. If you edit one, edit the other.
|
||||||
|
|
||||||
|
## Shared protocol
|
||||||
|
|
||||||
|
### Startup ritual (every session)
|
||||||
|
|
||||||
|
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
|
||||||
|
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
|
||||||
|
3. Read `.ai/HANDOFF.md` — exact resume point.
|
||||||
|
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
|
||||||
|
5. Run `git log --oneline -15` and `git status`.
|
||||||
|
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
|
||||||
|
|
||||||
|
### Handoff ritual (session end — limit hit, task complete, or user stop)
|
||||||
|
|
||||||
|
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
|
||||||
|
2. If `CURRENT_TASK.md` status changed, update it.
|
||||||
|
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
|
||||||
|
4. Append a session entry to `.ai/SESSION_LOG.md`.
|
||||||
|
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
|
||||||
|
|
||||||
|
### Writing rules for .ai/ files
|
||||||
|
|
||||||
|
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
|
||||||
|
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
|
||||||
|
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
|
||||||
|
|
||||||
|
### Project principle
|
||||||
|
|
||||||
|
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||||
|
|
||||||
|
## Codex-specific notes
|
||||||
|
|
||||||
|
### Tooling you do NOT have
|
||||||
|
|
||||||
|
- **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files.
|
||||||
|
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow.
|
||||||
|
- **No `/codex` second-opinion command.** You are Codex.
|
||||||
|
|
||||||
|
### Git trailer
|
||||||
|
|
||||||
|
Every commit: `Co-Authored-By: Codex <noreply@openai.com>`
|
||||||
|
|
||||||
|
### Model selection
|
||||||
|
|
||||||
|
Handled on OpenAI's side. Do not attempt to set Anthropic model aliases for your own runtime. (The repo's application code still uses Anthropic aliases like `claude-sonnet-4-6` via `settings.get_model_for_action()` — that's runtime config for the product, not your agent.)
|
||||||
|
|
||||||
|
### Reviewing Claude's work
|
||||||
|
|
||||||
|
When you resume from a Claude session, assume some decisions may have been informed by GitNexus queries or gstack commands whose output isn't in the handoff. If a decision looks unverified from the `.ai/` files alone, either:
|
||||||
|
|
||||||
|
- re-verify with `grep`/`rg`/file reads, or
|
||||||
|
- flag it in `HANDOFF.md` under "Open questions" so Michael or Claude can confirm on the next handoff.
|
||||||
|
|
||||||
|
Do not assume tooling output that isn't written down.
|
||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to ResolutionFlow are documented here.
|
All notable changes to ResolutionFlow are documented here.
|
||||||
|
|
||||||
|
## [0.1.0.0] - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
|
||||||
|
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
|
||||||
|
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
|
||||||
|
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
|
||||||
|
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
|
||||||
|
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
|
||||||
|
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
|
||||||
|
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
|
||||||
|
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
|
||||||
|
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
|
||||||
|
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
|
||||||
|
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
|
||||||
|
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
|
||||||
|
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
|
||||||
|
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
|
||||||
|
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
|
||||||
|
- Assignment search and "Load More" removed from resource manager in favor of direct member list
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
592
CLAUDE.md
592
CLAUDE.md
@@ -1,570 +1,74 @@
|
|||||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
# CLAUDE.md — ResolutionFlow
|
||||||
|
|
||||||
> **Last Updated:** April 6, 2026
|
You are Claude Code, the primary coding agent for ResolutionFlow. OpenAI Codex is the resume agent when you hit session or weekly limits.
|
||||||
|
|
||||||
---
|
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
|
||||||
|
|
||||||
## Project Overview
|
> The protocol section below is byte-identical to the shared block in AGENTS.md. If you edit one, edit the other.
|
||||||
|
|
||||||
**Patherly** (user-facing brand: **ResolutionFlow**) is a **SaaS product for MSP professionals**. It provides troubleshooting decision trees that guide engineers through proven troubleshooting paths, capture decisions and notes, and generate professional ticket documentation.
|
## Shared protocol
|
||||||
|
|
||||||
**Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients.
|
### Startup ritual (every session)
|
||||||
|
|
||||||
**SaaS Context:** Multi-tenant design — teams represent MSP companies, trees shared within teams, tiered access (super_admin, team_admin, engineer, viewer).
|
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
|
||||||
|
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
|
||||||
|
3. Read `.ai/HANDOFF.md` — exact resume point.
|
||||||
|
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
|
||||||
|
5. Run `git log --oneline -15` and `git status`.
|
||||||
|
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
|
||||||
|
|
||||||
### Branding
|
### Handoff ritual (session end — limit hit, task complete, or user stop)
|
||||||
|
|
||||||
| Context | Name Used |
|
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
|
||||||
|---------|-----------|
|
2. If `CURRENT_TASK.md` status changed, update it.
|
||||||
| Repository / directory / database | `patherly` (internal name) |
|
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
|
||||||
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
|
4. Append a session entry to `.ai/SESSION_LOG.md`.
|
||||||
| Backend, frontend UI, production URLs | **ResolutionFlow** |
|
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
|
||||||
|
|
||||||
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
|
### Writing rules for .ai/ files
|
||||||
- **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:** Electric blue (#60a5fa dark / #2563eb light). Used sparingly — ≤5% of the UI. Warning is amber (#fbbf24), info is cyan (#67e8f9).
|
|
||||||
- **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 (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:** 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.
|
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
|
||||||
|
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
|
||||||
|
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
|
||||||
|
|
||||||
## Implementation Principles
|
### Project principle
|
||||||
|
|
||||||
- Prefer correct architecture over minimal diff
|
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||||
- If two approaches exist, implement the one that scales, not the one that's faster to write
|
|
||||||
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
|
|
||||||
|
|
||||||
---
|
## Claude-specific tooling
|
||||||
|
|
||||||
## Current State
|
### GitNexus code intelligence
|
||||||
|
|
||||||
- **Phase:** Go-to-Market Validation (Pre-PMF)
|
Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
|
||||||
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
|
|
||||||
- **Frontend:** Core features complete, Tree Editor functional
|
|
||||||
- **Database:** PostgreSQL with Docker, 101 migrations
|
|
||||||
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
|
|
||||||
|
|
||||||
### What's In Progress
|
| Tool | When |
|
||||||
|
|---|---|
|
||||||
|
| `gitnexus_query({query})` | Find code by concept when you don't know where to look |
|
||||||
|
| `gitnexus_context({name})` | Callers/callees of a symbol before touching it |
|
||||||
|
| `gitnexus_impact({target, direction})` | Blast radius before editing shared symbols |
|
||||||
|
| `gitnexus_rename({symbol_name, new_name, dry_run: true})` | Safe multi-file rename |
|
||||||
|
|
||||||
- GTM validation: Shadow & Ship — founder dogfooding for 2 weeks, then 5 colleague pilot
|
**Use for:** core shared symbols (`flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks), cross-file renames, unfamiliar bug traces, refactor safety. **Skip for:** new endpoints, isolated fixes, changes you can read in one file.
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale: `npx gitnexus analyze`.
|
||||||
|
|
||||||
## Tech Stack
|
### gstack skills
|
||||||
|
|
||||||
### Backend
|
Always use `/browse` for web, never `mcp__claude-in-chrome__*`.
|
||||||
|
|
||||||
- **Framework:** Python FastAPI
|
Available commands:
|
||||||
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
|
|
||||||
- **Migrations:** Alembic
|
|
||||||
- **Auth:** JWT (python-jose) + bcrypt, refresh token rotation (JTI-based)
|
|
||||||
- **Validation:** Pydantic v2
|
|
||||||
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
|
|
||||||
|
|
||||||
### Frontend
|
- **Planning & review:** `/autoplan`, `/plan-eng-review`, `/plan-design-review`, `/plan-ceo-review`, `/plan-devex-review`, `/devex-review`, `/review`, `/cso`, `/office-hours`
|
||||||
|
- **Design:** `/design-consultation`, `/design-shotgun`, `/design-html`, `/design-review`
|
||||||
|
- **Browser & QA:** `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/setup-browser-cookies`
|
||||||
|
- **Ship & deploy:** `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/setup-deploy`, `/document-release`
|
||||||
|
- **Debug & investigate:** `/investigate`, `/careful`, `/freeze`, `/guard`, `/unfreeze`
|
||||||
|
- **Other:** `/codex` (OpenAI second opinion), `/setup-gbrain`, `/retro`, `/learn`, `/gstack-upgrade`
|
||||||
|
|
||||||
- **Framework:** React 19 + Vite + TypeScript
|
### Git trailer
|
||||||
- **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
|
|
||||||
- **Icons:** Lucide React
|
|
||||||
|
|
||||||
---
|
Every commit: `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`
|
||||||
|
|
||||||
## Key Project Structure
|
### Model aliases
|
||||||
|
|
||||||
```
|
Always use alias form (`claude-sonnet-4-6`, `claude-opus-4-6`, etc.) via `settings.get_model_for_action()`. Never hardcode a dated model ID.
|
||||||
patherly/
|
|
||||||
├── backend/
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── main.py # FastAPI entry point
|
|
||||||
│ │ ├── 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)
|
|
||||||
│ │ ├── api/router.py # Route registration
|
|
||||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
|
||||||
│ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
|
|
||||||
│ │ ├── schemas/ # Pydantic schemas
|
|
||||||
│ │ ├── 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
|
|
||||||
│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
|
|
||||||
│ ├── scripts/ # seed_data.py, seed_trees.py
|
|
||||||
│ └── tests/ # pytest integration tests
|
|
||||||
├── frontend/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # Axios client + endpoint modules
|
|
||||||
│ │ ├── 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, scriptGeneratorStore)
|
|
||||||
│ │ └── types/ # TypeScript interfaces
|
|
||||||
│ └── (Tailwind v4: CSS-only config in src/index.css)
|
|
||||||
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
|
|
||||||
├── CLAUDE.md # This file
|
|
||||||
├── CURRENT-STATE.md # Detailed feature status
|
|
||||||
├── LESSONS-LEARNED.md # (Deprecated — consolidated into CLAUDE.md)
|
|
||||||
└── docs/plans/ # Design docs & implementation plans
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### Backend (`backend/.env`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_NAME=ResolutionFlow
|
|
||||||
DEBUG=true
|
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/patherly
|
|
||||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@localhost:5432/patherly
|
|
||||||
SECRET_KEY=<openssl rand -hex 32>
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=5
|
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
|
||||||
REQUIRE_INVITE_CODE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend (`frontend/.env.local` - optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
VITE_API_URL=http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ConnectWise PSA Integration
|
|
||||||
|
|
||||||
ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
|
|
||||||
|
|
||||||
### Best Practices Documentation
|
|
||||||
|
|
||||||
Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code:
|
|
||||||
|
|
||||||
- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST.
|
|
||||||
- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
|
|
||||||
- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
|
|
||||||
- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
|
|
||||||
- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`.
|
|
||||||
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
|
|
||||||
- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
|
|
||||||
- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly.
|
|
||||||
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
|
|
||||||
- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL).
|
|
||||||
|
|
||||||
### Reference Files (read in this order)
|
|
||||||
|
|
||||||
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows.
|
|
||||||
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details.
|
|
||||||
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset.
|
|
||||||
|
|
||||||
### Integration Architecture
|
|
||||||
|
|
||||||
- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes`
|
|
||||||
- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context
|
|
||||||
- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows
|
|
||||||
|
|
||||||
### Key Implementation Rules
|
|
||||||
|
|
||||||
- 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/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)
|
|
||||||
- In-memory TTL cache in `services/psa/cache.py` for board/status/priority lookups
|
|
||||||
- Respect CW API: paginate with max 1000 per page, handle retries gracefully
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
|
||||||
docker start resolutionflow_postgres
|
|
||||||
|
|
||||||
# Backend (from backend/)
|
|
||||||
source venv/bin/activate # Linux/Mac
|
|
||||||
# .\venv\Scripts\Activate # Windows
|
|
||||||
uvicorn app.main:app --reload
|
|
||||||
|
|
||||||
# Frontend (from frontend/)
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Run tests (from backend/)
|
|
||||||
pytest --override-ini="addopts="
|
|
||||||
|
|
||||||
# First time only: create test database
|
|
||||||
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
|
||||||
|
|
||||||
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
|
||||||
cd frontend && npm run build
|
|
||||||
|
|
||||||
# Database migrations
|
|
||||||
cd backend && alembic upgrade head
|
|
||||||
alembic revision --autogenerate -m "Description"
|
|
||||||
# Sequential 3-digit IDs (001–070) were used historically. New migrations use Alembic's default hex hash IDs.
|
|
||||||
# Do NOT pass --rev-id — let Alembic generate the hash automatically.
|
|
||||||
|
|
||||||
# Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
|
|
||||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
|
||||||
|
|
||||||
# Seed data
|
|
||||||
cd backend && pip install httpx && python -m scripts.seed_trees
|
|
||||||
|
|
||||||
# CI/CD debugging
|
|
||||||
# CI runs on Gitea (gitea.resolutionflow.com), NOT GitHub Actions — gh run list will return nothing useful
|
|
||||||
# Check CI status at: https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
|
|
||||||
# `gh` CLI is still used for GitHub Issues/PRs (mirrored repo), not for CI runs
|
|
||||||
```
|
|
||||||
|
|
||||||
### URLs
|
|
||||||
|
|
||||||
- Frontend: <http://localhost:5173>
|
|
||||||
- Backend API: <http://localhost:8000>
|
|
||||||
- API Docs: <http://localhost:8000/api/docs>
|
|
||||||
|
|
||||||
### Test Users (seeded via `scripts/seed_test_users.py`)
|
|
||||||
|
|
||||||
- All share password: `TestPass123!`
|
|
||||||
- `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical Lessons Learned
|
|
||||||
|
|
||||||
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes are baked into the codebase. Consult if you hit a regression.
|
|
||||||
|
|
||||||
### Active Lessons (41+)
|
|
||||||
|
|
||||||
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
|
||||||
|
|
||||||
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
|
||||||
|
|
||||||
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
|
||||||
|
|
||||||
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
|
||||||
|
|
||||||
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
|
||||||
|
|
||||||
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
|
||||||
|
|
||||||
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Actions route to model tiers via `settings.get_model_for_action()`. Delta responses use `[DELTA]...[/DELTA]` markers.
|
|
||||||
|
|
||||||
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
|
||||||
|
|
||||||
**49. Full-stack features — verify both ends:** Check the full data flow: schema → endpoint → API client → hook → store → UI.
|
|
||||||
|
|
||||||
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
|
||||||
|
|
||||||
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: use alias form (`claude-sonnet-4-6`).
|
|
||||||
|
|
||||||
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
|
||||||
|
|
||||||
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
|
||||||
|
|
||||||
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
|
||||||
|
|
||||||
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
|
||||||
|
|
||||||
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
|
||||||
|
|
||||||
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
|
||||||
|
|
||||||
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
|
||||||
|
|
||||||
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
|
||||||
|
|
||||||
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` or `VITE_PUBLIC_*` env var must be added as `ARG` + `ENV` in `frontend/Dockerfile` for Railway deploys. Railway env vars are runtime-only unless explicitly passed through as Docker build args. Without this, `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
|
||||||
|
|
||||||
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — there is no intake form screen or "Start" button. Variables are filled inline during execution. Troubleshooting flows DO have a start screen with ticket/client fields. Don't write tests or UI that assume a Start button on procedural flows.
|
|
||||||
|
|
||||||
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
|
|
||||||
|
|
||||||
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
|
||||||
|
|
||||||
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
|
|
||||||
|
|
||||||
**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 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.
|
|
||||||
|
|
||||||
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping scheduler runs can process the same records twice (TOCTOU race). Always set `max_instances=1` on interval jobs in `main.py`.
|
|
||||||
|
|
||||||
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields, or JSON serialization may produce unexpected types.
|
|
||||||
|
|
||||||
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`. See `frontend/src/lib/toast.ts`.
|
|
||||||
|
|
||||||
**71. Enhancement/branch_addition proposals cannot be directly approved:** Backend returns 400 — they require `modified_flow_data` via "Edit & Publish" flow. Only `new_flow` proposals support direct approve.
|
|
||||||
|
|
||||||
**72. `ai_sessions.status` column is `VARCHAR(30)`:** Must fit `requesting_escalation` (23 chars). If adding new status values, verify length. Migration `f0aad74ea51b` widened from 20→30.
|
|
||||||
|
|
||||||
**73. `get_db` rolls back on exception:** The dependency does `await session.rollback()` on error to prevent `InFailedSQLTransaction` cascade. Never remove this — without it, one failed request poisons subsequent requests on the same connection.
|
|
||||||
|
|
||||||
**74. FlowPilot action bar height chain:** The action bar (Resolve/Escalate/Pause) requires every ancestor from `app-shell` grid down to have proper flex constraints. Key fix: `ViewTransitionOutlet` wrapper needs `flex flex-col`. If action bar disappears, check height chain with DevTools `getBoundingClientRect()` walk.
|
|
||||||
|
|
||||||
**75. Dashboard prefill auto-submits:** `StartSessionInput` navigates to `/pilot` or `/assistant` with `{ state: { prefill } }`. `FlowPilotSessionPage` auto-submits via `useEffect` + `prefillHandledRef` guard — no double-enter. `AssistantChatPage` does the same pattern.
|
|
||||||
|
|
||||||
**76. Active session navigation guard:** `FlowPilotSessionPage` uses `useBlocker` (same as `TreeEditorPage`) to intercept navigation during active sessions. "Pause & Leave" auto-pauses before proceeding.
|
|
||||||
|
|
||||||
**77. Prefer manual Alembic migrations for targeted changes:** `alembic revision --autogenerate` picks up drift from all tables. For single-column fixes, use `alembic revision -m "desc"` and write `op.alter_column()` manually.
|
|
||||||
|
|
||||||
**78. Landing page subtitle is "AI-Powered Troubleshooting for MSPs":** Not "Decision Tree Platform". This tagline appears on login, register, and the HTML `<title>`. The old "Decision Tree Platform" was internal jargon misaligned with user-facing branding.
|
|
||||||
|
|
||||||
**79. Custom modals must be mobile-responsive:** Use `items-end sm:items-center` (bottom-sheet on mobile, centered on desktop) and `max-w-full sm:max-w-lg` (full-width on mobile). The shared `Modal.tsx` does this correctly — custom modal implementations must follow the same pattern. See `PrepareSessionModal.tsx` for the fix pattern.
|
|
||||||
|
|
||||||
**80. TopBar search collapses to icon on mobile:** Full search bar (`hidden sm:block`) shows on desktop; magnifying glass icon button (`sm:hidden`) shows on mobile (<640px). Both open the same CommandPalette. Don't add `w-full` search bar without the mobile icon fallback.
|
|
||||||
|
|
||||||
**81. Never use `transition: all` in landing.css:** Specify exact properties: `transition: background 0.3s, border-color 0.3s, box-shadow 0.3s, transform 0.3s, opacity 0.3s`. `transition: all` animates layout properties and causes jank.
|
|
||||||
|
|
||||||
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
|
||||||
|
|
||||||
**83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
|
|
||||||
|
|
||||||
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
|
|
||||||
|
|
||||||
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
|
|
||||||
|
|
||||||
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
|
|
||||||
|
|
||||||
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
|
|
||||||
|
|
||||||
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan.
|
|
||||||
|
|
||||||
*(Lessons 89–91 were retracted.)*
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**93. FlowPilot actions live in the page header, not a bottom bar:** `FlowPilotSessionPage` renders Resolve/Escalate/Share Update in the header bar. Desktop: inline buttons + `⋯` overflow (Pause/Close). Mobile: single `⋯` menu. The bottom only has the message input. `FlowPilotActionBar` component still exists but is no longer used in the main session flow.
|
|
||||||
|
|
||||||
**94. Frontend chat uses unified_chat_service, not assistant_chat_service:** `AssistantChatPage` calls `/ai-sessions/{id}/chat` → `unified_chat_service.py`. The old `assistant_chat_service` endpoints were removed (only retention settings remain at `/assistant/retention`). When tracing chat features, start from `aiSessionsApi.sendChatMessage` → `ai_sessions.py` → `unified_chat_service.py`. Never wire chat features into `assistant_chat.py`.
|
|
||||||
|
|
||||||
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
|
|
||||||
|
|
||||||
**96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
|
|
||||||
|
|
||||||
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**104. `landing.css` uses self-contained `--lp-*` color variables:** The landing page defines its own color palette at the top of `landing.css` (`--lp-bg`, `--lp-accent`, `--lp-text-*`, etc.). Never use `var(--color-*)` theme tokens in `landing.css` — they may resolve incorrectly outside the app shell context. Extend the `--lp-*` palette for any new landing page colors.
|
|
||||||
|
|
||||||
**105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`.
|
|
||||||
|
|
||||||
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
|
|
||||||
|
|
||||||
**107. Startup routines must use `_admin_session_factory()` after Phase 4 RLS:** Any code that runs at startup (lifespan, `ensure_service_account`, seed scripts) and touches tenant-isolated tables (`users`, etc.) must use `_admin_session_factory()` — not `get_db()`. Phase 4 enabled RLS on `users`; a tenant-scoped session has no `app.current_account_id` set at startup, so all queries return 0 rows or fail. `get_service_account_id` in `deps.py` is safe — it reads from `app.state` cached at startup, never hits the DB per-request.
|
|
||||||
|
|
||||||
**108. Tables with no `account_id` column (never add to RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts` — global/platform tables documented with "No account_id. No RLS." in their model files. When writing RLS migrations, scan at the class level (check for `account_id: Mapped` within the class block), not the file level — multiple classes in one `.py` file can have different columns (e.g. `ScriptCategory` vs `ScriptTemplate` in `script_template.py`).
|
|
||||||
|
|
||||||
**109. `tree_shares.account_id` must equal `tree.account_id`, not the actor's account:** When creating a `TreeShare`, always use `account_id=tree.account_id` (tree owner's tenant). A super admin in tenant A sharing tenant B's tree must produce a share row in tenant B's RLS context — using `current_user.account_id` instead makes the share invisible to the tree owner after RLS is enforced.
|
|
||||||
|
|
||||||
**110. Backfill migrations for `account_id` require a service-code audit:** When a migration adds `account_id` to an existing model via backfill (nullable → backfill → NOT NULL), grep for ALL `ModelClass(` instantiation sites in service code and verify `account_id=` is passed. SQLAlchemy accepts `None` silently with no warning; Phase 4 RLS WITH CHECK only surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`. Fixed example: `AISessionStep` — all 5 creation sites in `flowpilot_engine.py` were missing `account_id` until April 2026.
|
|
||||||
|
|
||||||
**111. Global Axios interceptor fires before component `.catch()` — fix optional-data endpoints at the source:** The global 5xx handler in `client.ts` fires for ALL non-401 5xx responses, even when a component does `.catch(() => {})`. If an endpoint returns optional UI data (e.g., board filters, PSA config), return `[]` / `{}` on provider failure rather than raising 502. Silencing the error in the component is not enough — the toast appears anyway. See `list_boards` in `integrations.py` for the fixed pattern.
|
|
||||||
|
|
||||||
## RBAC & Permissions
|
|
||||||
|
|
||||||
- **Role hierarchy:** super_admin > team_admin > engineer > viewer
|
|
||||||
- **Team Admin:** `role='engineer'` + `is_team_admin=True` + valid `team_id`
|
|
||||||
- **Backend deps:** `get_current_active_user(user, db)` (any active + auto-downgrades expired trials), `require_engineer_or_admin` (blocks viewers), `require_admin` (super admin only)
|
|
||||||
- **Never use** `role == "admin"` — use `is_super_admin` instead
|
|
||||||
- **Frontend:** `usePermissions()` hook for all permission checks
|
|
||||||
- **Centralized:** `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design System
|
|
||||||
|
|
||||||
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
|
|
||||||
|
|
||||||
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
|
|
||||||
- **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
|
|
||||||
- **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
|
|
||||||
- **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
|
|
||||||
- **Inputs:** `bg-input` (`#252830`) 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-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
|
|
||||||
- **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
|
|
||||||
- **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
|
|
||||||
- **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
|
|
||||||
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Patterns
|
|
||||||
|
|
||||||
- **Component guidelines:** Use `cn()` from `@/lib/utils`, Lucide icons (wrap in `<span>` for title), modals with fixed header/footer
|
|
||||||
- **Type organization:** Create in `types/`, export from `types/index.ts`, import with `import type { T } from '@/types'`
|
|
||||||
- **Scratchpad overlay:** `position: fixed`, `onOpenChange` callback for parent padding adjustment, `right-2` positioning
|
|
||||||
- **Custom step flow:** `CustomStepModal` → `PostStepActionModal` → `ContinuationModal` → custom step view. Key state: `pendingStep`, `pendingContinuationNodeId`, `customBranchMode`, `branchOriginNodeId`. Use `findCustomStep()` not `findNode()` for custom step UUIDs.
|
|
||||||
- **Session sharing:** `ShareSessionModal` manages share links, `SharedSessionPage` renders public/account views. Helper utils in `lib/sessionShare.ts`. Share URLs use `/shared/sessions/:token`.
|
|
||||||
- **Procedural navigation:** `ProceduralNavigationPage` handles intake forms, step-by-step execution, and resume via `location.state.sessionId`. Uses `StepChecklist`, `StepDetail`, `ProgressBar`, `CompletionSummary` components.
|
|
||||||
- **Routing helper:** Use `getTreeNavigatePath()` and `getTreeEditorPath()` from `@/lib/routing` for all tree/session navigation.
|
|
||||||
- **Account section layout:** `AccountLayout` has NO sidebar nav. Account sub-pages (categories, target-lists) are reached via link cards on `AccountSettingsPage.tsx`. New account pages: add route in `router.tsx` under `account` children + add a link card in `AccountSettingsPage`.
|
|
||||||
- **Dashboard cockpit:** `QuickStartPage` is the copilot-first launchpad. Greeting + "What are you troubleshooting?" + ChatGPT-style `StartSessionInput` (auto-growing textarea, paste images, drag-drop files, attach button, paste logs, suggestion chips). Below: `PendingEscalations`, `ActiveFlowPilotSessions`, `RecentFlowPilotSessions`. Collapsible "Dashboard" section for `PerformanceCards`, `KnowledgeBaseCards`, `TeamSummary`.
|
|
||||||
- **Sidebar sections:** Amber "New Session" button → Home → RESOLVE (History) → KNOWLEDGE (Flows with Solutions Library sub-item, Scripts) → INSIGHTS (Data). Footer: Account, Pin/Unpin. No help/guides/feedback in sidebar — accessible via TopBar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
|
||||||
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
|
||||||
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
|
|
||||||
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head`
|
|
||||||
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coding Standards
|
|
||||||
|
|
||||||
### Python
|
|
||||||
|
|
||||||
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
|
|
||||||
|
|
||||||
### Git
|
|
||||||
|
|
||||||
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
|
||||||
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
|
||||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
|
||||||
- Large features: commit per phase with `npm run build` validation
|
|
||||||
- **Remote is Gitea, not GitHub directly:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Gitea auto-mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub.
|
|
||||||
|
|
||||||
### After Completing Work
|
|
||||||
|
|
||||||
When a feature, fix, or significant piece of work is finished and merged/committed:
|
|
||||||
|
|
||||||
1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections
|
|
||||||
2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status
|
|
||||||
3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work
|
|
||||||
4. **Update `CLAUDE.md`** if the work introduced new patterns, lessons learned, or changed project structure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## gstack (Browser & Workflow Skills)
|
|
||||||
|
|
||||||
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
|
|
||||||
|
|
||||||
**Available skills:**
|
|
||||||
|
|
||||||
| Skill | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
|
|
||||||
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
|
|
||||||
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
|
|
||||||
| `/plan-design-review` | Design plan review (UI/UX critique) |
|
|
||||||
| `/design-consultation` | Create a design system / DESIGN.md |
|
|
||||||
| `/review` | Pre-landing PR code review |
|
|
||||||
| `/ship` | Ship workflow (tests, review, PR creation) |
|
|
||||||
| `/browse` | Headless browser for QA testing and site dogfooding |
|
|
||||||
| `/qa` | Systematic QA testing + auto-fix bugs found |
|
|
||||||
| `/qa-only` | QA report only (no fixes) |
|
|
||||||
| `/design-review` | Visual QA — find and fix design inconsistencies |
|
|
||||||
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
|
|
||||||
| `/retro` | Weekly engineering retrospective |
|
|
||||||
| `/investigate` | Systematic debugging with root cause analysis |
|
|
||||||
| `/document-release` | Post-ship documentation updates |
|
|
||||||
| `/codex` | Second opinion via OpenAI Codex CLI |
|
|
||||||
| `/careful` | Safety guardrails for destructive commands |
|
|
||||||
| `/freeze` | Restrict edits to a specific directory |
|
|
||||||
| `/guard` | Full safety mode (careful + freeze) |
|
|
||||||
| `/unfreeze` | Remove edit restrictions |
|
|
||||||
| `/gstack-upgrade` | Upgrade gstack to latest version |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment (Railway)
|
|
||||||
|
|
||||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
|
||||||
- Auto-deploys via: push to Gitea → Gitea mirrors to GitHub → Railway watches GitHub `main` and deploys
|
|
||||||
- PR environments auto-created (need manual domain generation in Railway dashboard)
|
|
||||||
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
|
||||||
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
|
||||||
- Shared Variables (project-level in Railway dashboard) auto-propagate to all environments including PR envs — use for secrets like `ANTHROPIC_API_KEY`
|
|
||||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Roadmap
|
|
||||||
|
|
||||||
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
|
|
||||||
- **Phase 4:** Additional PSA integrations (Autotask/Kaseya), PowerShell automation, enterprise SSO
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
| What | Where |
|
|
||||||
|------|-------|
|
|
||||||
| API Docs | <http://localhost:8000/api/docs> |
|
|
||||||
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
|
||||||
| 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 |
|
|
||||||
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
|
||||||
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
|
|
||||||
|
|
||||||
|
|
||||||
<!-- gitnexus:start -->
|
|
||||||
# GitNexus — Code Intelligence
|
|
||||||
|
|
||||||
This project is indexed by GitNexus as **resolutionflow**. Use it selectively — for routine additive work (new endpoints, new components, isolated fixes) just read the files directly. GitNexus earns its cost when you're about to touch something genuinely central with many callers.
|
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
|
||||||
|
|
||||||
## When to Use It
|
|
||||||
|
|
||||||
**Use GitNexus when:**
|
|
||||||
- Touching a core shared symbol with many callers — `flowpilot_engine`, `unified_chat_service`, auth middleware, `get_db`, shared hooks
|
|
||||||
- Renaming anything used across multiple files
|
|
||||||
- Tracing an unfamiliar bug through a call chain you haven't read
|
|
||||||
- Assessing whether a refactor is safe before starting
|
|
||||||
|
|
||||||
**Skip GitNexus when:**
|
|
||||||
- Adding a new endpoint, component, or isolated feature
|
|
||||||
- Fixing a bug in a self-contained file
|
|
||||||
- Making changes you can already see the full scope of by reading the file
|
|
||||||
|
|
||||||
## Useful Tools
|
|
||||||
|
|
||||||
| Tool | When to use | Command |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `query` | Find code by concept when you don't know where to look | `gitnexus_query({query: "auth validation"})` |
|
|
||||||
| `context` | See all callers/callees of a symbol before touching it | `gitnexus_context({name: "symbolName"})` |
|
|
||||||
| `impact` | Blast radius check before editing a shared symbol | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
|
||||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
|
||||||
|
|
||||||
## Keeping the Index Fresh
|
|
||||||
|
|
||||||
A PostToolUse hook re-indexes automatically after `git commit`. To manually refresh:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx gitnexus analyze
|
|
||||||
```
|
|
||||||
|
|
||||||
<!-- gitnexus:end -->
|
|
||||||
|
|||||||
783
DEV-ENV.md
783
DEV-ENV.md
@@ -1,262 +1,671 @@
|
|||||||
# ResolutionFlow Dev Environment Setup & Operations Guide
|
# ResolutionFlow — Dev Environment Setup & Operations Guide
|
||||||
|
|
||||||
## Server Overview
|
> **Scope:** Stand up a working ResolutionFlow dev environment from scratch on any Linux host (VPS, on-prem Proxmox LXC/VM, bare metal). Self-contained — do not read another doc to get the dev stack running.
|
||||||
|
> **Last rewritten:** April 2026, post-Hostinger-VPS deprecation, ahead of Proxmox migration.
|
||||||
|
> **Audience:** You (returning to the project), a teammate, or a fresh Claude Code session.
|
||||||
|
|
||||||
- **Provider:** Hostinger KVM VPS (srv1522117)
|
If you're picking up mid-migration and need to know what code state is on the current branch, read `docs/FlowAssist_Migration/MIGRATION-HANDOFF.md` first.
|
||||||
- **IP Address:** 46.202.92.250
|
|
||||||
- **OS:** Ubuntu 24.04 LTS
|
|
||||||
- **CPU:** 2 vCPU cores
|
|
||||||
- **RAM:** 8GB
|
|
||||||
- **Disk:** 100GB NVMe SSD
|
|
||||||
- **Swap:** 4GB (`/swapfile`, swappiness=10)
|
|
||||||
|
|
||||||
## Architecture
|
---
|
||||||
|
|
||||||
All services run as Docker containers on the host, managed via SSH or from the VS Code Server integrated terminal.
|
## 1. What this project needs, regardless of host
|
||||||
|
|
||||||
```
|
These are non-negotiable. If your host can't provide them, fix that before anything else.
|
||||||
Host (root@srv1522117)
|
|
||||||
├── Traefik → reverse proxy + auto SSL (Let's Encrypt)
|
|
||||||
├── VS Code Server → browser IDE at https://code.resolutionflow.com
|
|
||||||
└── ResolutionFlow Stack
|
|
||||||
├── resolutionflow_frontend → Vite/React on port 5173
|
|
||||||
├── resolutionflow_backend → FastAPI/Uvicorn on port 8000
|
|
||||||
└── resolutionflow_postgres → PostgreSQL 16 + pgvector on port 5432
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access URLs
|
| Component | Required version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Linux** | any mainstream distro | Ubuntu 22.04+ / Debian 12+ tested; Alpine fine for containers |
|
||||||
|
| **Python** | 3.11+ | Backend and migrations |
|
||||||
|
| **Node.js** | 20.19+ | Vite 7 fails on older versions — CLAUDE.md Lesson 63 |
|
||||||
|
| **PostgreSQL** | 16 | `gen_random_uuid()` + `jsonb` + RLS are all leaned on |
|
||||||
|
| **Docker + Docker Compose** | recent | Only if you are running Postgres and/or backend as containers |
|
||||||
|
| **Git** | recent | |
|
||||||
|
|
||||||
| Service | URL |
|
Optional but recommended:
|
||||||
|
|
||||||
|
| Tool | Why |
|
||||||
|---|---|
|
|---|---|
|
||||||
| VS Code Server | https://code.resolutionflow.com |
|
| **code-server** | Browser-based VS Code; how this project has historically been edited |
|
||||||
| Frontend (dev) | http://46.202.92.250:5173 |
|
| **`gh` CLI** | Mirror repo is on GitHub via Gitea; `gh` reads issues and PRs |
|
||||||
| Backend API | http://46.202.92.250:8000 |
|
| **bun** | Required for the gstack `/browse` + `/qa` skills (CLAUDE.md Lesson 82) |
|
||||||
| API Docs | http://46.202.92.250:8000/docs |
|
| **`npx gitnexus analyze`** | Code-graph for Phase 2+ work that touches `unified_chat_service` |
|
||||||
|
| **Claude Code CLI** | If you want to run Claude Code locally on the host |
|
||||||
|
|
||||||
## Docker Layout
|
---
|
||||||
|
|
||||||
|
## 2. Architectural shape
|
||||||
|
|
||||||
|
The project is three services plus your editor. Keep these facts in mind regardless of topology:
|
||||||
|
|
||||||
```
|
```
|
||||||
/docker/
|
Your browser
|
||||||
├── traefik/
|
├─► code-server (editor, optional — usually port 8080 or behind TLS)
|
||||||
│ ├── docker-compose.yml → Traefik reverse proxy
|
├─► frontend (Vite) (dev server, port 5173)
|
||||||
│ └── .env → ACME_EMAIL for Let's Encrypt
|
└─► backend (FastAPI) (dev server, port 8000)
|
||||||
└── vscode/
|
│
|
||||||
├── docker-compose.yml → VS Code Server
|
└─► PostgreSQL (port 5432)
|
||||||
└── .env → CODE_PASSWORD
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Project lives inside the VS Code Server Docker volume:
|
**The frontend calls the backend by URL at runtime.** The frontend does not proxy through the backend. Whatever URL your browser uses to reach the backend is what `VITE_API_URL` must be set to, **baked in at build time**. Changing `VITE_API_URL` requires rebuilding the frontend.
|
||||||
|
|
||||||
|
**The backend calls the database by URL at runtime.** The URL depends on where Postgres is relative to the backend — Docker service name if both are in the same compose network, `localhost` if Postgres is native on the same host, or a DNS name if they're in separate containers/VMs.
|
||||||
|
|
||||||
|
**CORS is configured explicitly.** The backend's `CORS_ORIGINS` list must include every origin your browser will use to reach the frontend. A missing origin shows up as failed preflight requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Topology choices — pick one before you start
|
||||||
|
|
||||||
|
The project is agnostic to topology, but each shape has different setup steps.
|
||||||
|
|
||||||
|
### Option A — all-in-one LXC/VM/host (simplest)
|
||||||
|
|
||||||
|
Postgres, backend, and frontend all run on one Linux host. code-server runs on the same host or a sibling. No Docker required. Best for a single-developer Proxmox LXC.
|
||||||
|
|
||||||
|
### Option B — Docker Compose on one host
|
||||||
|
|
||||||
|
Postgres, backend, and frontend run as Docker containers on one host. code-server runs outside the compose network (on the host or in another container). This is how the old Hostinger VPS was configured. Best if you want reproducible container images.
|
||||||
|
|
||||||
|
### Option C — split services across containers/VMs
|
||||||
|
|
||||||
|
Postgres in one container/VM, backend and frontend in another, code-server in a third. Most complex; requires explicit networking between them. Use only if you have a specific reason.
|
||||||
|
|
||||||
|
**Pick one and stick with it for the entire setup.** Mixing Options A and B halfway through is where setup runs off the rails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Per-host configuration
|
||||||
|
|
||||||
|
These values are specific to your host. Fill them in once and reference them by name throughout the rest of the doc.
|
||||||
|
|
||||||
```
|
```
|
||||||
/var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow/
|
DEV_HOST = <hostname or IP your browser uses, e.g. dev.internal, 10.0.0.42>
|
||||||
|
DEV_HOST_SCHEME = <http or https; http is fine for internal dev, https if behind a TLS proxy>
|
||||||
|
FRONTEND_PORT = 5173
|
||||||
|
BACKEND_PORT = 8000
|
||||||
|
POSTGRES_PORT = 5433 # host-side port. 5433 is the recommended default on any shared host to avoid collision with a host-level Postgres. The container's internal port stays 5432.
|
||||||
|
POSTGRES_DB_NAME = resolutionflow
|
||||||
|
POSTGRES_USER = postgres
|
||||||
|
POSTGRES_PASSWORD = <local-dev-password; anything, this is not prod>
|
||||||
|
SECRET_KEY = <openssl rand -hex 32 — generate fresh per host, do not reuse>
|
||||||
|
ANTHROPIC_API_KEY = <from https://console.anthropic.com>
|
||||||
|
GOOGLE_AI_API_KEY = <optional, only if using Gemini as a fallback>
|
||||||
```
|
```
|
||||||
|
|
||||||
## VS Code Server
|
Store these somewhere you can copy from during setup. Do not commit them.
|
||||||
|
|
||||||
- **Container user:** `coder` (UID 1000)
|
> **Naming note:** the canonical database name is `resolutionflow`. If you see `patherly` in a config file, that's drift from an earlier rename and is being swept in a separate commit — use `resolutionflow`. CLAUDE.md tracks the live-code files that still reference `patherly`.
|
||||||
- **Home directory:** `/home/coder`
|
|
||||||
- **Project location:** `/home/coder/resolutionflow`
|
|
||||||
- **Host volume path:** `/var/lib/docker/volumes/vscode_vscode-data/_data`
|
|
||||||
- **Access URL:** `https://code.resolutionflow.com`
|
|
||||||
- **HTTPS:** Auto-provisioned via Traefik + Let's Encrypt
|
|
||||||
|
|
||||||
### Compose File Location
|
---
|
||||||
`/docker/vscode/docker-compose.yml`
|
|
||||||
|
|
||||||
## Traefik
|
## 5. Setup procedure
|
||||||
|
|
||||||
Handles reverse proxying and automatic SSL for all services. HTTP automatically redirects to HTTPS.
|
Run these in order. Stop at the first failure and investigate.
|
||||||
|
|
||||||
### Adding A New Service Behind Traefik
|
### 5.1 Install system dependencies
|
||||||
|
|
||||||
Add these labels to any new Docker service:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.<n>.rule=Host(`subdomain.resolutionflow.com`)"
|
|
||||||
- "traefik.http.routers.<n>.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.<n>.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.<n>.loadbalancer.server.port=<port>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Also create an A record in DNS pointing the subdomain to `46.202.92.250`.
|
|
||||||
|
|
||||||
## ResolutionFlow Dev Stack
|
|
||||||
|
|
||||||
### Important: No Docker Inside VS Code Container
|
|
||||||
|
|
||||||
The VS Code Server container does NOT have Docker. All `docker compose` commands must be run via SSH as root on the host.
|
|
||||||
|
|
||||||
### Environment Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `.env` | Root — Docker Compose interpolation (`SECRET_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_AI_API_KEY`, `POSTGRES_PORT`) |
|
|
||||||
| `backend/.env` | Backend source of truth — all FastAPI settings, API keys, DB URLs, CORS |
|
|
||||||
| `frontend/.env` | Frontend — `VITE_API_URL` pointing to backend |
|
|
||||||
|
|
||||||
### Critical Remote Access Config
|
|
||||||
|
|
||||||
**`frontend/.env`:**
|
|
||||||
```
|
|
||||||
VITE_API_URL=http://46.202.92.250:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
**`backend/.env`:**
|
|
||||||
```
|
|
||||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://46.202.92.250:5173","http://46.202.92.250:3000","https://resolutionflow.com","https://www.resolutionflow.com"]
|
|
||||||
FRONTEND_URL=http://46.202.92.250:5173
|
|
||||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
|
|
||||||
DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `DATABASE_URL` uses `@db:5432` (Docker service name), not `@localhost`.
|
|
||||||
|
|
||||||
**`docker-compose.dev.yml`:**
|
|
||||||
```yaml
|
|
||||||
- VITE_API_URL=http://46.202.92.250:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Starting the Dev Environment
|
|
||||||
|
|
||||||
SSH into host as root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
# Ubuntu / Debian
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
sudo apt update && sudo apt install -y \
|
||||||
|
git curl build-essential \
|
||||||
|
python3.11 python3.11-venv python3-pip \
|
||||||
|
postgresql-client # not the server — only if running Postgres natively
|
||||||
|
|
||||||
|
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||||
|
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
|
||||||
|
nvm install 20
|
||||||
|
nvm alias default 20
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Migrations (Fresh Database)
|
For Option B (Docker Compose), also:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
sudo usermod -aG docker $USER # log out and back in for this to take effect
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Clone the repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.resolutionflow.com/chihlasm/resolutionflow.git
|
||||||
|
# or the GitHub mirror:
|
||||||
|
# git clone https://github.com/chihlasm/resolutionflow.git
|
||||||
|
cd resolutionflow
|
||||||
|
|
||||||
|
# Check out the working branch if you're continuing mid-migration.
|
||||||
|
git fetch origin
|
||||||
|
git checkout feat/flowpilot-migration
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Start PostgreSQL
|
||||||
|
|
||||||
|
**Option A (native Postgres on the host):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y postgresql-16
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE resolutionflow;"
|
||||||
|
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
|
||||||
|
# Adjust pg_hba.conf if you need non-local connections.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B (Postgres via Docker Compose):** The repo has a `docker-compose.dev.yml` at the root. Check its Postgres service for the container name, port mapping, and volume. The local compose defaults use container name `resolutionflow_postgres`, database `resolutionflow`, and host-side port `5433` (mapped to the container's internal `5432`) — see CLAUDE.md Lesson 65. The host-side `5433` is the recommended default on any shared host: it keeps the port free for a host-level Postgres if you ever need one. The compose file also defines explicit `command:` directives on both `backend` and `frontend` to force `--host 0.0.0.0`, and expects the caller to pass `REPO_ROOT` (see 5.4) for bind-mount resolution. Confirm what the compose file actually says on your branch before trusting these values.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d db
|
||||||
|
docker compose -f docker-compose.dev.yml logs db # wait for "ready to accept connections"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the host (Option A) or the backend container/LXC (Option B):
|
||||||
|
psql -h <db-host> -p <POSTGRES_PORT> -U postgres -d resolutionflow -c "SELECT now();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Write the `.env` files
|
||||||
|
|
||||||
|
The repo expects three env files. Create each one:
|
||||||
|
|
||||||
|
**`backend/.env`** — backend source of truth:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_NAME=ResolutionFlow
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
|
# DB URLs — `<db-host>` is `localhost` for Option A, the Docker service name
|
||||||
|
# (e.g. `db`) for Option B, or the DB container/VM hostname for Option C.
|
||||||
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
|
||||||
|
DATABASE_URL_SYNC=postgresql://postgres:postgres@<db-host>:<POSTGRES_PORT>/resolutionflow
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
SECRET_KEY=<SECRET_KEY>
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=5
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
REQUIRE_INVITE_CODE=true
|
||||||
|
|
||||||
|
# AI providers
|
||||||
|
AI_PROVIDER=anthropic
|
||||||
|
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
|
||||||
|
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
|
||||||
|
|
||||||
|
# FlowPilot MCP telemetry — leave on so the Phase 0.5 baseline data keeps accruing
|
||||||
|
ENABLE_MCP_MICROSOFT_LEARN=true
|
||||||
|
|
||||||
|
# CORS + frontend URL
|
||||||
|
FRONTEND_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>
|
||||||
|
CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`frontend/.env.local`** — frontend build-time config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_URL=<DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional PostHog (CLAUDE.md Lesson 64 — enables product analytics locally):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_PUBLIC_POSTHOG_KEY=<from PostHog project settings>
|
||||||
|
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repo root `.env`** — only needed for Option B (Docker Compose interpolation):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SECRET_KEY=<SECRET_KEY>
|
||||||
|
ANTHROPIC_API_KEY=<ANTHROPIC_API_KEY>
|
||||||
|
GOOGLE_AI_API_KEY=<GOOGLE_AI_API_KEY or leave unset>
|
||||||
|
POSTGRES_PORT=<POSTGRES_PORT>
|
||||||
|
# Absolute host-side path to the repo root. REQUIRED whenever docker-compose is
|
||||||
|
# invoked from inside a container (e.g. a code-server container with the host
|
||||||
|
# Docker socket mounted in). Without it, the bind mounts in
|
||||||
|
# docker-compose.dev.yml (`${REPO_ROOT}/backend:/app`, `${REPO_ROOT}/frontend:/app`)
|
||||||
|
# resolve against the CLI's CWD — a path the host daemon cannot see — and
|
||||||
|
# Docker silently creates empty directories there instead of mounting the code.
|
||||||
|
# If you run docker compose directly on the host shell, you can set this to `.`
|
||||||
|
# or the absolute path of the repo; being explicit is safer either way.
|
||||||
|
REPO_ROOT=/absolute/path/to/resolutionflow
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Never commit any `.env` file.** The `.gitignore` already covers this.
|
||||||
|
|
||||||
|
### 5.5 Run the backend setup
|
||||||
|
|
||||||
|
**Option A (native):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3.11 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Migrate the DB to head.
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B (Docker):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d backend
|
||||||
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### Seeding Test Users
|
**Expected alembic head** (as of `feat/flowpilot-migration`): `f07010f17b01`. If `alembic current` shows anything else after `upgrade head`, something has gone wrong — stop and investigate.
|
||||||
|
|
||||||
|
### 5.6 Seed test users
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Option A
|
||||||
|
cd backend && source venv/bin/activate
|
||||||
|
python -m scripts.seed_test_users
|
||||||
|
|
||||||
|
# Option B
|
||||||
docker exec resolutionflow_backend python -m scripts.seed_test_users
|
docker exec resolutionflow_backend python -m scripts.seed_test_users
|
||||||
```
|
```
|
||||||
|
|
||||||
Test accounts (password: `TestPass123!`):
|
Test users (all share password `TestPass123!`):
|
||||||
|
|
||||||
| Email | Role | Plan |
|
| Email | Role |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| admin@resolutionflow.example.com | Owner | Team |
|
| `admin@resolutionflow.example.com` | super admin |
|
||||||
| pro@resolutionflow.example.com | Owner | Pro |
|
| `teamadmin@resolutionflow.example.com` | team admin |
|
||||||
| teamadmin@resolutionflow.example.com | Owner | Team |
|
| `engineer@resolutionflow.example.com` | engineer |
|
||||||
| engineer@resolutionflow.example.com | Engineer | Shared |
|
| `pro@resolutionflow.example.com` | solo pro |
|
||||||
|
|
||||||
### Rebuilding After Config Changes
|
### 5.7 Run the backend
|
||||||
|
|
||||||
|
**Option A:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && source venv/bin/activate
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B:** Already running from `docker compose up -d backend`. Tail logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:** `curl <DEV_HOST_SCHEME>://<DEV_HOST>:<BACKEND_PORT>/api/docs` — OpenAPI docs page loads.
|
||||||
|
|
||||||
|
### 5.8 Run the frontend
|
||||||
|
|
||||||
|
**Option A:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B:**
|
||||||
|
|
||||||
**Frontend** (Vite bakes env vars at build time — requires rebuild):
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
|
||||||
docker compose -f docker-compose.dev.yml up -d --build frontend
|
docker compose -f docker-compose.dev.yml up -d --build frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
**Backend** (restart only):
|
**Verify:** Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>` in your browser. Log in with one of the test users. Navigate to `/pilot` — the FlowPilot session page should render.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Verification — proof the env actually works
|
||||||
|
|
||||||
|
Run these after setup. Every item has a concrete expected outcome.
|
||||||
|
|
||||||
|
### 6.1 Database schema is at the right version
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Option A
|
||||||
|
cd backend && source venv/bin/activate && alembic current
|
||||||
|
# Option B
|
||||||
|
docker compose -f docker-compose.dev.yml run --rm backend alembic current
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `f07010f17b01 (head)` on the `feat/flowpilot-migration` branch. On `main`, expected: `074 (head)`.
|
||||||
|
|
||||||
|
### 6.2 Alembic reversibility
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic downgrade -1 # should complete cleanly
|
||||||
|
alembic upgrade head # should return to f07010f17b01
|
||||||
|
```
|
||||||
|
|
||||||
|
If either step fails, the migration has a bug and Phase 2 cannot start.
|
||||||
|
|
||||||
|
### 6.3 Prompt-cache hit verification (the deferred Phase 0 TODO)
|
||||||
|
|
||||||
|
`backend/app/core/ai_provider.py` module docstring has a `TODO(phase0-verify)` note describing this. Procedure:
|
||||||
|
|
||||||
|
1. Confirm `AI_PROVIDER=anthropic` and `ANTHROPIC_API_KEY` is set in `backend/.env`.
|
||||||
|
2. Start the backend with log level INFO or lower.
|
||||||
|
3. In the UI, open `/pilot` and send a chat message. Wait a few seconds for the response.
|
||||||
|
4. Send a second chat message in the same session, within 5 minutes of the first.
|
||||||
|
5. In backend logs, grep for lines containing `anthropic.cache`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A
|
||||||
|
grep 'anthropic.cache' <log-path>
|
||||||
|
# Option B
|
||||||
|
docker compose -f docker-compose.dev.yml logs backend | grep 'anthropic.cache'
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Expected: two `anthropic.cache` log events. First has `cache_creation_input_tokens > 0`. Second has `cache_read_input_tokens > 0`.
|
||||||
|
7. If the second shows zero reads, inspect the prompt prefix for silent invalidators (timestamps, unsorted JSON keys, varying tool list ordering). Fix before proceeding with any Phase 2 work.
|
||||||
|
|
||||||
|
### 6.4 Frontend build is TypeScript-clean
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx tsc -b # no errors
|
||||||
|
npm run build # no errors
|
||||||
|
```
|
||||||
|
|
||||||
|
CLAUDE.md Lesson 105 notes that `npm run build` may fail with an `EACCES` on `dist/` inside code-server — that is a Docker filesystem permission issue, not a real build error. Use `npx tsc -b` to verify TypeScript cleanliness in that case.
|
||||||
|
|
||||||
|
### 6.5 `/assistant` → `/pilot` redirect
|
||||||
|
|
||||||
|
Open `<DEV_HOST_SCHEME>://<DEV_HOST>:<FRONTEND_PORT>/assistant/<some-real-session-id>` in the browser. Expected: URL changes to `/pilot/<that-id>`; the FlowPilot session page renders. Bare `/assistant` redirects to bare `/pilot`.
|
||||||
|
|
||||||
|
### 6.6 Dispatcher de-branching
|
||||||
|
|
||||||
|
Navigate to the dashboard. Click a session in `ActiveFlowPilotSessions` or `RecentFlowPilotSessions`. Expected: routes to `/pilot/:id` regardless of the session's `session_type` value. (Check the browser URL bar.)
|
||||||
|
|
||||||
|
### 6.7 CORS
|
||||||
|
|
||||||
|
Open the browser DevTools Network tab, navigate to any backend-hitting page. Expected: no CORS errors. If you see "blocked by CORS policy," the missing origin needs adding to `backend/.env`'s `CORS_ORIGINS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Runbook
|
||||||
|
|
||||||
|
Day-to-day commands after setup is complete.
|
||||||
|
|
||||||
|
### Restart services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A
|
||||||
|
# backend — Ctrl-C and re-run uvicorn
|
||||||
|
# frontend — Ctrl-C and re-run npm run dev
|
||||||
|
|
||||||
|
# Option B
|
||||||
docker compose -f docker-compose.dev.yml restart backend
|
docker compose -f docker-compose.dev.yml restart backend
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build frontend # rebuild required if VITE_* changed
|
||||||
|
docker compose -f docker-compose.dev.yml down && docker compose -f docker-compose.dev.yml up -d # full restart
|
||||||
```
|
```
|
||||||
|
|
||||||
**Full restart:**
|
### Apply a new migration
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml down
|
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installed Tools (Inside VS Code Server Container)
|
|
||||||
|
|
||||||
Installed in `/home/coder` — persists via Docker volume:
|
|
||||||
|
|
||||||
- **nvm** — Node version manager
|
|
||||||
- **Node.js 20.x** — via nvm, default alias set
|
|
||||||
- **npm** — latest
|
|
||||||
- **GitHub CLI (gh)** — authenticated via personal access token
|
|
||||||
- **Claude Code CLI** — `@anthropic-ai/claude-code` (global npm)
|
|
||||||
|
|
||||||
### Permanent Tool Installs
|
|
||||||
|
|
||||||
Tools installed via `apt` inside the container do NOT survive container rebuilds. To add permanently, modify the VS Code Server Docker image and rebuild.
|
|
||||||
|
|
||||||
Temporary (session only):
|
|
||||||
```bash
|
|
||||||
sudo apt update && sudo apt install -y <tool>
|
|
||||||
```
|
|
||||||
|
|
||||||
## SSH Access
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@46.202.92.250
|
# Option A
|
||||||
|
cd backend && source venv/bin/activate && alembic upgrade head
|
||||||
|
# Option B
|
||||||
|
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
Key auth configured via `~/.ssh/authorized_keys` on host.
|
### Create a new migration
|
||||||
|
|
||||||
## Useful Commands
|
|
||||||
|
|
||||||
### Check all running containers
|
|
||||||
```bash
|
```bash
|
||||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
# Option A
|
||||||
|
cd backend && source venv/bin/activate
|
||||||
|
alembic revision -m "short description" # manual, preferred per CLAUDE.md Lesson 77
|
||||||
|
# OR
|
||||||
|
alembic revision --autogenerate -m "description" # pulls in drift; review carefully
|
||||||
```
|
```
|
||||||
|
|
||||||
### View container logs
|
Never pass `--rev-id` — let Alembic generate the hex hash.
|
||||||
|
|
||||||
|
### Inspect the database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker logs <container_name> --tail 30 -f
|
# Option A (native Postgres)
|
||||||
|
psql -h localhost -p 5432 -U postgres -d resolutionflow
|
||||||
|
|
||||||
|
# Option B (Docker)
|
||||||
|
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart VS Code Server
|
### Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /docker/vscode && docker compose restart
|
# Option A
|
||||||
|
cd backend && source venv/bin/activate
|
||||||
|
pytest --override-ini="addopts="
|
||||||
|
|
||||||
|
# Option B
|
||||||
|
docker compose -f docker-compose.dev.yml run --rm backend pytest --override-ini="addopts="
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart Traefik
|
First time only, create the test database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /docker/traefik && docker compose restart
|
# Option A
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE resolutionflow_test;"
|
||||||
|
|
||||||
|
# Option B
|
||||||
|
docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restart dev stack
|
### View backend logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/lib/docker/volumes/vscode_vscode-data/_data/resolutionflow
|
# Option A: wherever you ran uvicorn
|
||||||
docker compose -f docker-compose.dev.yml down
|
# Option B
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml logs -f --tail=100 backend
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check swap
|
Structured events to grep for:
|
||||||
|
- `anthropic.cache` — prompt-cache hit/creation telemetry (Phase 0.1)
|
||||||
|
- `mcp.turn` — per-turn MCP availability/invocation (Phase 0.5)
|
||||||
|
- `mcp.fallback` — MCP silent-retry fallback fired (Phase 0.5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Troubleshooting
|
||||||
|
|
||||||
|
### CORS errors in the browser
|
||||||
|
|
||||||
|
The backend did not accept the origin your browser used. Check `backend/.env`'s `CORS_ORIGINS` — it must include the exact scheme + host + port the browser sent. Restart the backend after editing.
|
||||||
|
|
||||||
|
### `VITE_API_URL` points at the wrong place
|
||||||
|
|
||||||
|
The frontend was built with a stale value. Rebuild the frontend. Option B: `docker compose up -d --build frontend`. Option A: restart `npm run dev`.
|
||||||
|
|
||||||
|
### `alembic upgrade head` fails with "target database is not up to date"
|
||||||
|
|
||||||
|
Your DB migration chain is out of sync with the code. On a dev box, the safe recovery is to drop the DB and re-migrate from scratch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
free -h && swapon --show
|
# Option A
|
||||||
|
sudo -u postgres psql -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
|
||||||
|
cd backend && source venv/bin/activate && alembic upgrade head
|
||||||
|
|
||||||
|
# Option B
|
||||||
|
docker exec resolutionflow_postgres psql -U postgres -c "DROP DATABASE resolutionflow;" -c "CREATE DATABASE resolutionflow;"
|
||||||
|
docker compose -f docker-compose.dev.yml run --rm backend alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check disk
|
Only do this on a dev box — it destroys all local data.
|
||||||
|
|
||||||
|
### `alembic heads` shows more than one head
|
||||||
|
|
||||||
|
Only on a local branch that has diverged from `origin/main`. Production `main` has a single head. If this happens on a fresh clone, one of your local migration files has the wrong `down_revision`. Inspect each file's `down_revision` and reconnect the chain.
|
||||||
|
|
||||||
|
### Frontend build fails with "EACCES: permission denied" on `dist/`
|
||||||
|
|
||||||
|
Filesystem permission issue inside the code-server container (CLAUDE.md Lesson 105). TypeScript compilation itself completes — use `npx tsc -b` to verify cleanliness without needing to write to `dist/`.
|
||||||
|
|
||||||
|
### Backend/frontend containers start but `/app` is empty (no code mounted)
|
||||||
|
|
||||||
|
Almost always a `REPO_ROOT` problem. `docker-compose.dev.yml` uses `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app` bind mounts. If `REPO_ROOT` is unset, or set to a path that doesn't exist *on the Docker host* (not inside the code-server container), Docker silently creates an empty directory at that path and mounts it — the containers come up but have no source code. Symptom: backend returns import errors, or frontend serves a default Vite page. Fix: set `REPO_ROOT` in the repo-root `.env` to the absolute host-side path to the repo, then `docker compose down && docker compose up -d`. See 5.4 for the full note. This matters specifically when `docker compose` is invoked from inside a container (e.g. code-server with the host Docker socket mounted) — the CLI's CWD is container-local but the daemon resolves paths against the host filesystem.
|
||||||
|
|
||||||
|
### Frontend shows "Blocked request. This host is not allowed" in the browser
|
||||||
|
|
||||||
|
Vite 5+ ships DNS-rebinding protection that rejects any `Host:` header not in `server.allowedHosts`. The browser's hostname must be in that list. Edit `frontend/vite.config.ts` — the `server.allowedHosts` array should include every hostname you reach the dev server from (e.g. `'docker-01'`, `'localhost'`, `.ts.net` as a wildcard for Tailscale MagicDNS). Restart the Vite dev server (for Option B: `docker compose restart frontend`). This is unrelated to CORS — Vite blocks the request before any app code runs.
|
||||||
|
|
||||||
|
### `docker` command not found inside code-server
|
||||||
|
|
||||||
|
If your code-server is itself inside a container, Docker is probably not exposed to it. CLAUDE.md Lesson 103 was written for this case on the old VPS. On Proxmox, the fix depends on topology — either SSH to the host to run Docker commands, or mount the host's Docker socket into the code-server container.
|
||||||
|
|
||||||
|
### Backend returns 500 with `InsufficientPrivilegeError: new row violates row-level security policy`
|
||||||
|
|
||||||
|
RLS is enabled on a table your code wrote to without the right `account_id`. CLAUDE.md Lessons 107, 108, 110 cover this family of bugs. The fix is always at the service layer: make sure every model creation passes `account_id=` explicitly, and that startup routines that touch tenant-isolated tables use `_admin_session_factory()` rather than `get_db()`.
|
||||||
|
|
||||||
|
### Anthropic cache reads are zero on the second turn
|
||||||
|
|
||||||
|
Something in the cached prefix is changing between turns. Inspect the system-block list and the first N history messages for timestamps, `datetime.now()`, unsorted dict keys in JSON prompts, or varying tool-list order. The `anthropic.cache` telemetry shows exactly how many tokens were read vs created — use it to narrow down the invalidator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Security posture for dev environments
|
||||||
|
|
||||||
|
This doc is about dev, not production. But:
|
||||||
|
|
||||||
|
- Never commit `.env` files. The `.gitignore` covers this.
|
||||||
|
- `SECRET_KEY` should be generated per-host, not reused across environments.
|
||||||
|
- `ANTHROPIC_API_KEY` is billable — rotate if leaked into logs or chat.
|
||||||
|
- Postgres on a dev host should not be exposed to the internet. Bind it to `127.0.0.1` or to a private network interface only.
|
||||||
|
- If you expose the frontend or backend publicly (for teammates to test against), put it behind TLS with a real certificate. Do not let dev credentials travel over plain HTTP on the public internet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. What's not in this doc
|
||||||
|
|
||||||
|
- **Production deployment.** This is a dev-env doc. Production lives on Railway — see `CLAUDE.md`'s Deployment section.
|
||||||
|
- **How to set up Traefik or any particular reverse proxy.** Whichever proxy you use is your choice; the dev stack just needs something that routes `<host>:5173` and `<host>:8000` to the right services. **Direct port exposure over a private network** (Tailscale, WireGuard, a VPN, or a LAN behind a firewall) is a fully supported option for dev and is what the homelab reference topology in Section 11 uses — no reverse proxy, no TLS, just `http://<host>:5173` and `http://<host>:8000` reachable only from the private network. That's a perfectly reasonable choice; it's just not the only one.
|
||||||
|
- **How to configure code-server itself.** Install it however you prefer (native, Docker, LXC); point it at the repo, and the rest of this doc applies.
|
||||||
|
- **Where to host the Proxmox instance.** Up to you.
|
||||||
|
|
||||||
|
If something in this doc turns out to be wrong on your host, fix the doc. This is a living document — the whole point of rewriting it from the Hostinger-specific version was to make it survive host changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Reference topology: homelab Proxmox + code-server (Option B)
|
||||||
|
|
||||||
|
This section documents the first concrete host instantiation since the April 2026 host-agnostic rewrite. It's a worked example, not the canonical topology — Section 3's Option A/B/C framing still stands. If your setup looks different, follow Sections 1–10 and ignore this appendix.
|
||||||
|
|
||||||
|
### 11.1 Host
|
||||||
|
|
||||||
|
- **Hypervisor:** Proxmox (homelab).
|
||||||
|
- **VM:** `docker-01`, Debian 13, running Docker Engine + Docker Compose natively.
|
||||||
|
- **Tailscale IP:** `100.64.78.44`. MagicDNS hostname: `docker-01` (and the full `.ts.net` FQDN).
|
||||||
|
- **code-server:** runs on the same VM in its own container, with the host's Docker socket mounted in so it can drive `docker compose`. Its workspace bind-mounts the repo at `/opt/docker/code-server/workspace/resolutionflow`.
|
||||||
|
|
||||||
|
This is a concrete instance of Option B from Section 3: Postgres, backend, and frontend all run as containers from `docker-compose.dev.yml`; the editor lives outside that compose network.
|
||||||
|
|
||||||
|
### 11.2 Access pattern — direct port over Tailscale, no reverse proxy
|
||||||
|
|
||||||
|
The browser reaches the dev stack directly:
|
||||||
|
|
||||||
|
- Frontend: `http://docker-01:5173`
|
||||||
|
- Backend: `http://docker-01:8000`
|
||||||
|
- Backend API docs: `http://docker-01:8000/api/docs`
|
||||||
|
|
||||||
|
There is **no Caddy, no Traefik, no nginx, no TLS, no basic auth** in front of either service. The tailnet provides the wire encryption and access control — only devices on the tailnet can resolve `docker-01` or reach `100.64.78.44`, and Tailscale ACLs decide which of those devices are allowed to connect.
|
||||||
|
|
||||||
|
Why this choice:
|
||||||
|
|
||||||
|
- **Zero routing config to maintain.** There is no proxy rulebook to keep in sync with new services. Add a container, expose a port, you're done.
|
||||||
|
- **Backend-to-backend services stay private.** Redis, Celery workers, the planned ConnectWise proxy, the MCP server — none of them need to be reachable from the browser, so none of them need proxy rules. They stay inside the `resolutionflow` Docker network and talk by service name. The proxy would only ever have carried frontend and backend traffic, so the proxy's value was small relative to its maintenance cost.
|
||||||
|
- **Debuggability.** `curl http://docker-01:8000/api/docs` from any tailnet device works without auth headers, TLS handshakes, or DNS shenanigans.
|
||||||
|
|
||||||
|
Tradeoff: **this only works because every client device is on the tailnet.** If someone needed to test from a non-tailnet device, they'd either join the tailnet or we'd need to front the stack with a proxy. For the current single-developer setup, the tailnet-only assumption holds.
|
||||||
|
|
||||||
|
### 11.3 Per-host config values (as actually configured on `docker-01`)
|
||||||
|
|
||||||
|
Plugging these into Section 4's template:
|
||||||
|
|
||||||
|
```
|
||||||
|
DEV_HOST = docker-01
|
||||||
|
DEV_HOST_SCHEME = http
|
||||||
|
FRONTEND_PORT = 5173
|
||||||
|
BACKEND_PORT = 8000
|
||||||
|
POSTGRES_PORT = 5433 # host-side; container-internal stays 5432
|
||||||
|
POSTGRES_DB_NAME = resolutionflow
|
||||||
|
POSTGRES_USER = postgres
|
||||||
|
POSTGRES_PASSWORD = postgres # local-dev only
|
||||||
|
SECRET_KEY = <generated per host; do not reuse>
|
||||||
|
ANTHROPIC_API_KEY = <from console.anthropic.com>
|
||||||
|
GOOGLE_AI_API_KEY = <unset; Anthropic is sole provider in dev>
|
||||||
|
```
|
||||||
|
|
||||||
|
And the repo-root `.env` that `docker-compose.dev.yml` interpolates from:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
df -h
|
SECRET_KEY=<redacted>
|
||||||
|
ANTHROPIC_API_KEY=<redacted>
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check memory + container usage
|
### 11.4 Why `REPO_ROOT` is non-optional on this host
|
||||||
|
|
||||||
|
code-server runs inside a container. When you open a terminal in code-server and run `docker compose -f docker-compose.dev.yml up -d`, the Docker CLI talks to the *host* daemon via the mounted socket — but the CWD it reports (`/config/workspace/resolutionflow`) is a path that only exists inside the code-server container. The host daemon has never heard of it.
|
||||||
|
|
||||||
|
Relative bind mounts like `./backend:/app` therefore resolve against a path the host can't see, and Docker silently creates empty directories there rather than erroring out. The containers come up, but `/app` is empty.
|
||||||
|
|
||||||
|
`docker-compose.dev.yml` sidesteps this by using `${REPO_ROOT}/backend:/app` and `${REPO_ROOT}/frontend:/app`. `REPO_ROOT` must be set to the absolute path **on the host** (`/opt/docker/code-server/workspace/resolutionflow`), not the path inside the code-server container. Same contents, different mount point, different name.
|
||||||
|
|
||||||
|
If you ever run `docker compose` directly from a host shell (SSH'd into `docker-01`), set `REPO_ROOT` to `.` or the absolute host path. Being explicit is always safe; leaving it unset is the failure mode.
|
||||||
|
|
||||||
|
### 11.5 Vite `server.allowedHosts` — required for `docker-01` to resolve
|
||||||
|
|
||||||
|
Vite 5+ rejects any `Host:` header not in `server.allowedHosts` (DNS-rebinding protection). `frontend/vite.config.ts` has:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: ['docker-01', '.ts.net', 'localhost'],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `docker-01` — the MagicDNS short name the browser uses day-to-day.
|
||||||
|
- `.ts.net` — wildcard for the full Tailscale MagicDNS FQDN, in case anyone uses it.
|
||||||
|
- `localhost` — for the "am I serving anything at all" smoke-test from inside the container.
|
||||||
|
|
||||||
|
If you move this setup to a different host, add that host's hostname to `allowedHosts` or the browser will see "Blocked request. This host is not allowed." See Section 8's troubleshooting entry for the full symptom/fix.
|
||||||
|
|
||||||
|
### 11.6 CORS origins on this host
|
||||||
|
|
||||||
|
The `backend` service's `CORS_ORIGINS` environment variable is pinned in the compose file to:
|
||||||
|
|
||||||
|
```
|
||||||
|
["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The last two are what make browser calls from tailnet clients work — they cover both MagicDNS (`docker-01`) and the raw Tailscale IP. If you add a new hostname to reach the frontend from, also add the matching origin here and restart the backend.
|
||||||
|
|
||||||
|
### 11.7 Compose file shape (as of this writing)
|
||||||
|
|
||||||
|
`docker-compose.dev.yml` has been through a round of cleanup for this topology. Specifics worth knowing if you're comparing against older revisions of the file:
|
||||||
|
|
||||||
|
- **No Traefik labels.** They were removed — nothing in this topology uses Traefik.
|
||||||
|
- **No Hostinger-VPS-era origins** in `CORS_ORIGINS`.
|
||||||
|
- `Dockerfile.dev` for both `backend` and `frontend` is still the build source — this didn't change.
|
||||||
|
- Explicit `command:` directives on both `backend` (`uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload`) and `frontend` (`npm run dev -- --host 0.0.0.0 --port 5173`) — this guarantees `--host 0.0.0.0` regardless of what's baked into the image, so the services listen on all interfaces and are reachable from outside the container.
|
||||||
|
- `REPO_ROOT` is interpolated into both service volume mounts (see 11.4).
|
||||||
|
|
||||||
|
If you're adapting the file for a different host, the things most likely to need editing are `REPO_ROOT` (see 11.4), `CORS_ORIGINS` (see 11.6), `FRONTEND_URL`, `VITE_API_URL`, and `POSTGRES_PORT` if you want something other than `5433`.
|
||||||
|
|
||||||
|
### 11.8 End-to-end sanity check for this topology
|
||||||
|
|
||||||
|
From any device on the tailnet:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
free -h && docker stats --no-stream
|
# Backend reachable
|
||||||
|
curl -sSf http://docker-01:8000/api/docs >/dev/null && echo OK
|
||||||
|
|
||||||
|
# Frontend reachable
|
||||||
|
curl -sSf http://docker-01:5173 >/dev/null && echo OK
|
||||||
|
|
||||||
|
# Alembic head matches the branch expectation
|
||||||
|
docker exec resolutionflow_backend alembic current
|
||||||
|
# expect f07010f17b01 on feat/flowpilot-migration, 074 on main
|
||||||
|
|
||||||
|
# Postgres is alive inside the compose network
|
||||||
|
docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -c "SELECT now();"
|
||||||
```
|
```
|
||||||
|
|
||||||
## DNS Records (resolutionflow.com)
|
All four passing = the dev environment is live end-to-end.
|
||||||
|
|
||||||
| Type | Name | Value | Purpose |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A | code | 46.202.92.250 | VS Code Server |
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
- UFW is inactive — Traefik and Docker manage port exposure
|
|
||||||
- All public-facing services run through Traefik with valid HTTPS certs
|
|
||||||
- PostgreSQL port 5432 is exposed on all interfaces — restrict if needed in production
|
|
||||||
- Rotate API keys (Anthropic, Voyage) if ever exposed in logs or chat
|
|
||||||
- Never commit `.env` files to Git
|
|
||||||
|
|
||||||
## VS Code Server Browser Tips
|
|
||||||
|
|
||||||
- **Command Palette:** `F1`
|
|
||||||
- **Terminal:** Ctrl+`
|
|
||||||
- **Rename file:** `F2`
|
|
||||||
- **Go to definition:** `F12`
|
|
||||||
- **Find references:** `Shift+F12`
|
|
||||||
- **Context Menu:** `Alt + Right Click`
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# Session Handoff — Design System v4 Migration
|
|
||||||
|
|
||||||
> **For the next Claude session:** Read this file completely, internalize the context, then delete it (`rm SESSION-HANDOFF.md`). This is a one-time context transfer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Was Done This Session
|
|
||||||
|
|
||||||
### 1. FlowPilot Message Bar + AI Script Builder (MERGED to main)
|
|
||||||
- PR #118 merged. Always-visible message bar in FlowPilot sessions, AI Script Builder at `/script-builder`, library reorg (My/Team Scripts tabs), FlowPilot-to-Script-Builder handoff, session abandon/close, unified session history.
|
|
||||||
- Eng review completed: normalized `script_builder_messages` table, typed content helpers, 6 edge case tests.
|
|
||||||
|
|
||||||
### 2. Design System v4 Migration (PR #119, open, branch: `refactor/design-system-v4`)
|
|
||||||
- Complete frontend redesign from glassmorphism to flat dark theme (Sentry/PostHog-inspired)
|
|
||||||
- **CSS Foundation:** New color tokens in `index.css`, all via CSS custom properties. Light mode ready (just needs `.light` class values).
|
|
||||||
- **Icon Rail Sidebar:** 72px rail with 5 grouped icons (Home, Work, Knowledge, Insights, Help). Full-height resizable drawer on hover. Pin-to-expand to 260px. Mobile hamburger overlay.
|
|
||||||
- **Component Sweep:** ~200 files migrated. All hardcoded hex replaced with semantic Tailwind tokens (bg-card, text-foreground, border-border, etc.).
|
|
||||||
- **Landing Page:** Flat surfaces, no glow, solid buttons.
|
|
||||||
- **Interactive Shadows:** Dark-mode-aware — elevated surfaces + faint cyan accent glow (black shadows invisible on dark bg).
|
|
||||||
- **Stat Cards:** 3px colored left borders.
|
|
||||||
- **Tab Toggles:** Active state uses `tab-active-shadow` (elevated bg + faint glow).
|
|
||||||
|
|
||||||
### 3. GTM Strategy (from /office-hours)
|
|
||||||
- Shadow & Ship approach: Michael uses ResolutionFlow on real tickets for 2 weeks, then hands logins to 5 MSP colleagues. Key metric: unprompted return.
|
|
||||||
- Design doc at `~/.gstack/projects/patherly-patherly/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Needs To Be Done Next
|
|
||||||
|
|
||||||
### Immediate (Design System v4 polish)
|
|
||||||
1. **Home icon color fix:** The Home icon in the sidebar shouldn't have a cyan background when not active. Instead, the Home icon itself should always be cyan (brand accent), and only show the `bg-accent-dim` background when the route is actually `/`. Michael specifically requested this.
|
|
||||||
2. **Visual QA pass:** Michael hasn't done a full page-by-page walkthrough yet. Expect feedback on individual pages once he does.
|
|
||||||
3. **`font-label` cleanup:** ~10 files still reference `font-label` (deprecated alias for `font-mono`). Each needs inspection — some should be `font-mono`, others `font-sans text-xs`.
|
|
||||||
4. **Inline `style` attributes:** ~29 instances still use hardcoded hex in inline styles (sidebar, drawer, badges). Should be converted to CSS variable references or Tailwind classes where possible.
|
|
||||||
|
|
||||||
### Before Merging PR #119
|
|
||||||
- Run migrations: `docker exec resolutionflow_backend alembic upgrade head` (new tables from the Script Builder PR are on main now)
|
|
||||||
- Full visual QA with backend running
|
|
||||||
- Test mobile responsive (hamburger menu)
|
|
||||||
- Test FlowPilot session with new message bar + action bar positioning
|
|
||||||
|
|
||||||
### Future
|
|
||||||
- **Light mode toggle:** CSS variables are ready. Need to add `.light` class values in `index.css` + toggle in user settings/account page.
|
|
||||||
- **Script Builder testing:** The AI Script Builder hasn't been tested end-to-end with the backend running yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Files to Know
|
|
||||||
|
|
||||||
| File | What it does |
|
|
||||||
|------|-------------|
|
|
||||||
| `DESIGN-SYSTEM.md` | Single source of truth for all design decisions |
|
|
||||||
| `frontend/src/index.css` | CSS tokens, component utilities, shadow patterns |
|
|
||||||
| `frontend/src/components/layout/Sidebar.tsx` | Icon rail + drawer + pinned sidebar |
|
|
||||||
| `frontend/src/components/layout/AppLayout.tsx` | CSS Grid shell |
|
|
||||||
| `frontend/src/components/dashboard/StartSessionInput.tsx` | The Guided/Chat toggle |
|
|
||||||
| `frontend/src/components/dashboard/PerformanceCards.tsx` | Stat cards with colored borders |
|
|
||||||
|
|
||||||
## Key Lessons From This Session
|
|
||||||
|
|
||||||
- The component sweep agents missed `editor-ai/`, `guides/`, `maintenance/`, `scripts/`, `settings/` directories and `text-brand-dark` references. Always do a final grep audit after sweeps.
|
|
||||||
- `bg-[#hex]` hardcoding defeats the purpose of CSS variables. We had to do a second pass to replace 3,200+ hardcoded values with semantic tokens.
|
|
||||||
- Black shadows (`rgba(0,0,0,...)`) are invisible on dark backgrounds. Use elevated surfaces + faint accent glow instead.
|
|
||||||
- The sidebar flyout needed `position: fixed` to escape the CSS Grid cell clipping — `absolute` positioning was hidden behind the main content area.
|
|
||||||
- Flyout hover timing: individual item `onMouseLeave` was killing the flyout before the mouse reached the drawer. Only the outer wrapper should handle `onMouseLeave`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **After reading this file:** Save relevant context to your session memory, then run `rm SESSION-HANDOFF.md` and `git add -A && git commit -m "chore: remove session handoff file"`.
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""add fix outcome tracking columns to session_suggested_fixes
|
||||||
|
|
||||||
|
Adds: status, applied_at, verified_at, partial_notes, failure_reason,
|
||||||
|
ai_outcome_proposal.
|
||||||
|
|
||||||
|
status is the outcome dimension (did the fix work?), orthogonal to the
|
||||||
|
existing user_decision column (which script-path the engineer took).
|
||||||
|
|
||||||
|
Revision ID: 6492ec8d2d5b
|
||||||
|
Revises: f07010f17b01
|
||||||
|
Create Date: 2026-04-23 18:32:38.609719
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6492ec8d2d5b'
|
||||||
|
down_revision: Union[str, None] = 'f07010f17b01'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"session_suggested_fixes",
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'")),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"session_suggested_fixes",
|
||||||
|
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"session_suggested_fixes",
|
||||||
|
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"session_suggested_fixes",
|
||||||
|
sa.Column("partial_notes", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"session_suggested_fixes",
|
||||||
|
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"session_suggested_fixes",
|
||||||
|
sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True),
|
||||||
|
)
|
||||||
|
# Backfill before constraint creation so dismissed rows satisfy the new CHECK.
|
||||||
|
op.execute(
|
||||||
|
"UPDATE session_suggested_fixes "
|
||||||
|
"SET status = 'dismissed' "
|
||||||
|
"WHERE user_decision = 'dismissed'"
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_session_suggested_fixes_status",
|
||||||
|
"session_suggested_fixes",
|
||||||
|
"status IN ('proposed', 'applied_success', 'applied_failed', 'applied_partial', 'dismissed')",
|
||||||
|
)
|
||||||
|
op.alter_column("session_suggested_fixes", "status", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check")
|
||||||
|
op.drop_column("session_suggested_fixes", "ai_outcome_proposal")
|
||||||
|
op.drop_column("session_suggested_fixes", "failure_reason")
|
||||||
|
op.drop_column("session_suggested_fixes", "partial_notes")
|
||||||
|
op.drop_column("session_suggested_fixes", "verified_at")
|
||||||
|
op.drop_column("session_suggested_fixes", "applied_at")
|
||||||
|
op.drop_column("session_suggested_fixes", "status")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""add origin discriminator + inline idempotency to script_builder_sessions
|
||||||
|
|
||||||
|
Adds:
|
||||||
|
- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum
|
||||||
|
- invariant: pilot_inline rows must have ai_session_id
|
||||||
|
- partial unique index: one pilot_inline session per (user, pilot session)
|
||||||
|
|
||||||
|
Revision ID: 71efd2102f49
|
||||||
|
Revises: 6492ec8d2d5b
|
||||||
|
Create Date: 2026-04-24 04:22:10.819809
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '71efd2102f49'
|
||||||
|
down_revision = '6492ec8d2d5b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"script_builder_sessions",
|
||||||
|
sa.Column(
|
||||||
|
"origin",
|
||||||
|
sa.String(length=20),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'standalone'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_script_builder_sessions_origin",
|
||||||
|
"script_builder_sessions",
|
||||||
|
"origin IN ('standalone', 'pilot_inline')",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_script_builder_sessions_origin_ai_session",
|
||||||
|
"script_builder_sessions",
|
||||||
|
"origin <> 'pilot_inline' OR ai_session_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ux_script_builder_sessions_pilot_inline",
|
||||||
|
"script_builder_sessions",
|
||||||
|
["user_id", "ai_session_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("origin = 'pilot_inline'"),
|
||||||
|
)
|
||||||
|
# Drop the server_default — app code owns the default via model default.
|
||||||
|
op.alter_column("script_builder_sessions", "origin", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ux_script_builder_sessions_pilot_inline",
|
||||||
|
table_name="script_builder_sessions",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_script_builder_sessions_origin_ai_session",
|
||||||
|
"script_builder_sessions",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_constraint(
|
||||||
|
"ck_script_builder_sessions_origin",
|
||||||
|
"script_builder_sessions",
|
||||||
|
type_="check",
|
||||||
|
)
|
||||||
|
op.drop_column("script_builder_sessions", "origin")
|
||||||
@@ -16,6 +16,7 @@ from app.models.refresh_token import RefreshToken
|
|||||||
from app.core.email import EmailService
|
from app.core.email import EmailService
|
||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
from app.models.account_invite import AccountInvite
|
from app.models.account_invite import AccountInvite
|
||||||
|
from app.models.account_settings import AccountSettings
|
||||||
from app.models.subscription import Subscription
|
from app.models.subscription import Subscription
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
|
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
|
||||||
@@ -559,3 +560,65 @@ async def get_sso_status(
|
|||||||
sso_enabled=account.sso_enabled,
|
sso_enabled=account.sso_enabled,
|
||||||
sso_provider=account.sso_provider,
|
sso_provider=account.sso_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
|
||||||
|
#
|
||||||
|
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
|
||||||
|
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
|
||||||
|
# write. Any engineer-role user can read + update preferences because the
|
||||||
|
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
|
||||||
|
# are team-level toggles rather than account-owner-gated admin settings.
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPreferencesResponse(BaseModel):
|
||||||
|
preferences: dict
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPreferencesUpdate(BaseModel):
|
||||||
|
"""Merge-style update — each key in `preferences` overwrites that key in
|
||||||
|
the stored JSONB, other keys are preserved. Omit the body entirely to
|
||||||
|
no-op.
|
||||||
|
"""
|
||||||
|
preferences: dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
|
||||||
|
async def get_my_preferences(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
"""Return the current account's preferences JSONB (empty dict if no row)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AccountSettings.preferences).where(
|
||||||
|
AccountSettings.account_id == current_user.account_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prefs = result.scalar_one_or_none() or {}
|
||||||
|
return AccountPreferencesResponse(preferences=prefs)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
|
||||||
|
async def update_my_preferences(
|
||||||
|
data: AccountPreferencesUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
"""Upsert preference keys. Existing keys not present in the payload are kept.
|
||||||
|
|
||||||
|
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
|
||||||
|
from the post-resolve "Don't ask me again for this team" checkbox sets
|
||||||
|
just that key without clobbering any other preferences.
|
||||||
|
"""
|
||||||
|
for key, value in data.preferences.items():
|
||||||
|
await AccountSettings.set_setting(db, current_user.account_id, key, value)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Return the merged state so the client doesn't need a second GET.
|
||||||
|
result = await db.execute(
|
||||||
|
select(AccountSettings.preferences).where(
|
||||||
|
AccountSettings.account_id == current_user.account_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prefs = result.scalar_one_or_none() or {}
|
||||||
|
return AccountPreferencesResponse(preferences=prefs)
|
||||||
|
|||||||
221
backend/app/api/endpoints/draft_templates.py
Normal file
221
backend/app/api/endpoints/draft_templates.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""Draft template endpoints — Phase 6 post-resolve templatization flow.
|
||||||
|
|
||||||
|
Engineers who picked "Run now, templatize after resolve" on the three-option
|
||||||
|
dialog (Phase 5) generate a `draft_templates` row at decision time. After
|
||||||
|
the session resolves, the TemplatizePrompt component lets them either:
|
||||||
|
- Accept → promotes the draft to a real `script_templates` row
|
||||||
|
- Reject → marks the draft rejected, no library entry created
|
||||||
|
|
||||||
|
The Script Library sidebar uses the list endpoint to surface a
|
||||||
|
"X drafts ready to review" badge for the account.
|
||||||
|
|
||||||
|
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
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, require_engineer_or_admin
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.draft_template import DraftTemplate
|
||||||
|
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.draft_template import (
|
||||||
|
DraftTemplateAcceptRequest,
|
||||||
|
DraftTemplateAcceptResponse,
|
||||||
|
DraftTemplateListResponse,
|
||||||
|
DraftTemplateRejectResponse,
|
||||||
|
DraftTemplateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/draft-templates", tags=["draft-templates"])
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(name: str) -> str:
|
||||||
|
"""Same slug rule as scripts.create_template — lowercase, kebab-case, ASCII."""
|
||||||
|
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── List ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("", response_model=DraftTemplateListResponse)
|
||||||
|
async def list_drafts(
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
pending_only: bool = True,
|
||||||
|
) -> DraftTemplateListResponse:
|
||||||
|
"""List drafts for the current user's account.
|
||||||
|
|
||||||
|
Defaults to pending-only — that's what the Script Library badge counts
|
||||||
|
and what the post-resolve TemplatizePrompt iterates over. Pass
|
||||||
|
`pending_only=false` to include accepted/rejected for an audit view.
|
||||||
|
"""
|
||||||
|
stmt = select(DraftTemplate).order_by(DraftTemplate.created_at.desc())
|
||||||
|
if pending_only:
|
||||||
|
stmt = stmt.where(DraftTemplate.status == "pending")
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
drafts = list(result.scalars().all())
|
||||||
|
return DraftTemplateListResponse(
|
||||||
|
drafts=[DraftTemplateResponse.model_validate(d) for d in drafts]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Get one ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{draft_id}", response_model=DraftTemplateResponse)
|
||||||
|
async def get_draft(
|
||||||
|
draft_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> DraftTemplateResponse:
|
||||||
|
draft = await _load_draft_or_404(db, draft_id)
|
||||||
|
return DraftTemplateResponse.model_validate(draft)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accept ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{draft_id}/accept",
|
||||||
|
response_model=DraftTemplateAcceptResponse,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def accept_draft(
|
||||||
|
draft_id: UUID,
|
||||||
|
body: DraftTemplateAcceptRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> DraftTemplateAcceptResponse:
|
||||||
|
"""Promote a draft to a real `script_templates` row.
|
||||||
|
|
||||||
|
Provenance fields (`source_session_id`, `source_user_id`,
|
||||||
|
`source_ticket_ref`) are copied so the Script Library can render the
|
||||||
|
"generated from CW #X · resolved by Y · used N times" chip.
|
||||||
|
|
||||||
|
On success: draft.status='accepted', draft.promoted_template_id set,
|
||||||
|
draft.resolved_at set. The new template is owned by the engineer's team
|
||||||
|
(matches scripts.create_template's behavior).
|
||||||
|
|
||||||
|
Returns 409 if the draft is already accepted/rejected.
|
||||||
|
"""
|
||||||
|
draft = await _load_draft_or_404(db, draft_id)
|
||||||
|
if draft.status != "pending":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Draft is already {draft.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate the category exists and belongs to (or is global for) this account.
|
||||||
|
cat_result = await db.execute(
|
||||||
|
select(ScriptCategory).where(
|
||||||
|
ScriptCategory.id == body.category_id,
|
||||||
|
ScriptCategory.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if cat_result.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="category_id does not reference an active script category",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up source-session ticket ref for the provenance chip. RLS makes
|
||||||
|
# cross-account ai_session lookup impossible — the draft must belong to
|
||||||
|
# the same account as the requesting user.
|
||||||
|
source_session = (
|
||||||
|
await db.execute(
|
||||||
|
select(AISession).where(AISession.id == draft.source_session_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
source_ticket_ref = (
|
||||||
|
f"CW #{source_session.psa_ticket_id}"
|
||||||
|
if source_session and source_session.psa_ticket_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
slug = _slugify(body.name)
|
||||||
|
|
||||||
|
template = ScriptTemplate(
|
||||||
|
category_id=body.category_id,
|
||||||
|
team_id=current_user.team_id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
created_by=current_user.id,
|
||||||
|
name=body.name,
|
||||||
|
slug=slug,
|
||||||
|
description=body.description,
|
||||||
|
script_body=body.edited_body or draft.script_body,
|
||||||
|
parameters_schema=body.parameters_schema,
|
||||||
|
# FlowPilot provenance — drives the Script Library chip.
|
||||||
|
source_session_id=draft.source_session_id,
|
||||||
|
source_user_id=draft.source_user_id,
|
||||||
|
source_ticket_ref=source_ticket_ref,
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
await db.flush() # populate template.id
|
||||||
|
|
||||||
|
draft.status = "accepted"
|
||||||
|
draft.promoted_template_id = template.id
|
||||||
|
draft.resolved_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(template)
|
||||||
|
|
||||||
|
return DraftTemplateAcceptResponse(
|
||||||
|
draft_id=draft.id,
|
||||||
|
promoted_template_id=template.id,
|
||||||
|
template_slug=template.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reject ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{draft_id}/reject", response_model=DraftTemplateRejectResponse)
|
||||||
|
async def reject_draft(
|
||||||
|
draft_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> DraftTemplateRejectResponse:
|
||||||
|
"""Mark a draft rejected.
|
||||||
|
|
||||||
|
No template is created. The row stays for audit (so a team admin can see
|
||||||
|
the engineer reviewed and explicitly declined). Returns 409 on a draft
|
||||||
|
that's already accepted/rejected.
|
||||||
|
"""
|
||||||
|
draft = await _load_draft_or_404(db, draft_id)
|
||||||
|
if draft.status != "pending":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Draft is already {draft.status}",
|
||||||
|
)
|
||||||
|
draft.status = "rejected"
|
||||||
|
draft.resolved_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
return DraftTemplateRejectResponse(draft_id=draft.id, status="rejected")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _load_draft_or_404(
|
||||||
|
db: AsyncSession, draft_id: UUID
|
||||||
|
) -> DraftTemplate:
|
||||||
|
"""RLS-scoped draft load. 404 covers missing + cross-tenant."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DraftTemplate).where(DraftTemplate.id == draft_id)
|
||||||
|
)
|
||||||
|
draft = result.scalar_one_or_none()
|
||||||
|
if draft is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Draft template not found",
|
||||||
|
)
|
||||||
|
return draft
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""PSA integration endpoints — connection CRUD and test."""
|
"""PSA integration endpoints — connection CRUD and test."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
@@ -30,6 +33,17 @@ from app.schemas.psa_connection import (
|
|||||||
PSABoardResponse,
|
PSABoardResponse,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.schemas.psa_tickets import (
|
||||||
|
PSAResourceSchema,
|
||||||
|
PSATicketCreatedSchema,
|
||||||
|
PSATicketStatusUpdateSchema,
|
||||||
|
TicketCreatePayloadSchema,
|
||||||
|
PSAPrioritySchema,
|
||||||
|
TicketListResponseSchema,
|
||||||
|
AiParseRequestSchema,
|
||||||
|
AiParseResponseSchema,
|
||||||
|
)
|
||||||
|
import app.services.ticket_service as ticket_svc
|
||||||
from app.services.psa.encryption import (
|
from app.services.psa.encryption import (
|
||||||
decrypt_credentials,
|
decrypt_credentials,
|
||||||
encrypt_credentials,
|
encrypt_credentials,
|
||||||
@@ -362,33 +376,36 @@ async def list_boards(
|
|||||||
provider = await get_provider_for_account(current_user.account_id, db)
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
boards = await provider.list_boards()
|
boards = await provider.list_boards()
|
||||||
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||||
except PSAError:
|
except PSAError as e:
|
||||||
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
||||||
|
logger.warning("list_boards failed: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
@router.get("/tickets/search", response_model=TicketListResponseSchema)
|
||||||
async def search_tickets(
|
async def search_tickets(
|
||||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
query: str = "",
|
query: str = "",
|
||||||
board_id: int | None = None,
|
board_id: int | None = None,
|
||||||
status_id: int | None = None,
|
status_id: int | None = None,
|
||||||
|
status_name: str | None = None,
|
||||||
include_closed: bool = False,
|
include_closed: bool = False,
|
||||||
assigned_to_me: bool = False,
|
assigned_to_me: bool = False,
|
||||||
unassigned: bool = False,
|
unassigned: bool = False,
|
||||||
board_ids: str = "",
|
board_ids: str = "",
|
||||||
|
priority: str | None = None,
|
||||||
|
company_id: int | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 10,
|
page_size: int = 25,
|
||||||
):
|
):
|
||||||
"""Search ConnectWise tickets."""
|
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
|
||||||
if not current_user.account_id:
|
if not current_user.account_id:
|
||||||
raise HTTPException(status_code=400, detail="User has no account")
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
from app.services.psa.registry import get_provider_for_account
|
from app.services.psa.registry import get_provider_for_account
|
||||||
from app.services.psa.exceptions import PSAError
|
from app.services.psa.exceptions import PSAError
|
||||||
|
|
||||||
# Resolve assigned_to_me → member_identifier (CW login name for resources contains filter)
|
|
||||||
member_identifier: str | None = None
|
member_identifier: str | None = None
|
||||||
if assigned_to_me:
|
if assigned_to_me:
|
||||||
conn_result = await db.execute(
|
conn_result = await db.execute(
|
||||||
@@ -407,23 +424,18 @@ async def search_tickets(
|
|||||||
)
|
)
|
||||||
mapping = mapping_result.scalar_one_or_none()
|
mapping = mapping_result.scalar_one_or_none()
|
||||||
if not mapping:
|
if not mapping:
|
||||||
# No mapping for this user — return empty list
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
return []
|
|
||||||
|
|
||||||
from app.services.psa.registry import get_provider_for_account as _get_provider
|
|
||||||
from app.services.psa.exceptions import PSAError as _PSAError
|
|
||||||
try:
|
try:
|
||||||
_provider = await _get_provider(current_user.account_id, db)
|
_provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
cw_members = await _provider.list_members()
|
cw_members = await _provider.list_members()
|
||||||
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
|
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
|
||||||
if matched:
|
if matched:
|
||||||
member_identifier = matched.identifier
|
member_identifier = matched.identifier
|
||||||
else:
|
else:
|
||||||
return []
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
except _PSAError:
|
except PSAError:
|
||||||
return []
|
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
# Parse comma-separated board_ids
|
|
||||||
parsed_board_ids: list[int] = []
|
parsed_board_ids: list[int] = []
|
||||||
if board_ids:
|
if board_ids:
|
||||||
try:
|
try:
|
||||||
@@ -433,33 +445,250 @@ async def search_tickets(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
provider = await get_provider_for_account(current_user.account_id, db)
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
tickets = await provider.search_tickets(
|
result = await provider.search_tickets(
|
||||||
query,
|
query,
|
||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
status_id=status_id,
|
status_id=status_id,
|
||||||
|
status_name=status_name,
|
||||||
include_closed=include_closed,
|
include_closed=include_closed,
|
||||||
member_identifier=member_identifier,
|
member_identifier=member_identifier,
|
||||||
unassigned=unassigned,
|
unassigned=unassigned,
|
||||||
board_ids=parsed_board_ids,
|
board_ids=parsed_board_ids,
|
||||||
|
company_id=company_id,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
)
|
)
|
||||||
return [
|
items = [
|
||||||
PSATicketSearchResult(
|
PSATicketSearchResult(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
summary=t.summary,
|
summary=t.summary,
|
||||||
company_name=t.company_name,
|
company_name=t.company_name,
|
||||||
|
company_id=t.company_id,
|
||||||
board_name=t.board_name,
|
board_name=t.board_name,
|
||||||
|
board_id=t.board_id,
|
||||||
status_name=t.status_name,
|
status_name=t.status_name,
|
||||||
|
status_id=t.status_id,
|
||||||
priority_name=t.priority_name,
|
priority_name=t.priority_name,
|
||||||
|
priority_id=t.priority_id,
|
||||||
closed=t.closed,
|
closed=t.closed,
|
||||||
)
|
)
|
||||||
for t in tickets
|
for t in result.items
|
||||||
]
|
]
|
||||||
|
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
|
||||||
except PSAError as e:
|
except PSAError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
|
||||||
|
async def create_ticket(
|
||||||
|
data: TicketCreatePayloadSchema,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Create a new PSA ticket."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
from app.services.psa.types import TicketCreatePayload
|
||||||
|
try:
|
||||||
|
return await ticket_svc.create_ticket(
|
||||||
|
current_user.account_id,
|
||||||
|
TicketCreatePayload(**data.model_dump()),
|
||||||
|
db,
|
||||||
|
)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
|
||||||
|
async def ai_parse_ticket(
|
||||||
|
data: AiParseRequestSchema,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Parse natural language into a ticket pre-fill payload using Claude."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
import anthropic
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Fetch boards + members for context (both cached)
|
||||||
|
boards = []
|
||||||
|
members = []
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
boards = await provider.list_boards()
|
||||||
|
members = await provider.list_members()
|
||||||
|
except PSAError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
boards_list = [{"id": b.id, "name": b.name} for b in boards]
|
||||||
|
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
|
||||||
|
|
||||||
|
system_prompt = """You are a ticket triage assistant for an MSP help desk.
|
||||||
|
Extract structured ticket information from the engineer's natural language description.
|
||||||
|
Return ONLY valid JSON matching this exact schema — no other text:
|
||||||
|
{
|
||||||
|
"summary": "short one-line ticket title or null",
|
||||||
|
"board_id": "integer matching one of the provided boards or null",
|
||||||
|
"priority_name": "one of: Critical, High, Medium, Low, or null",
|
||||||
|
"description": "expanded description or null",
|
||||||
|
"assignee_identifier": "member identifier string from the provided members list or null",
|
||||||
|
"warnings": ["list of strings explaining what could not be resolved"]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
user_msg = f"""Available boards: {json.dumps(boards_list)}
|
||||||
|
Available members: {json.dumps(members_list[:50])}
|
||||||
|
|
||||||
|
Engineer's description: {data.prompt}"""
|
||||||
|
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
response_data = AiParseResponseSchema()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = anthropic.AsyncAnthropic(
|
||||||
|
api_key=settings.ANTHROPIC_API_KEY,
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
msg = await client.messages.create(
|
||||||
|
model=settings.get_model_for_action("default"),
|
||||||
|
max_tokens=512,
|
||||||
|
system=system_prompt,
|
||||||
|
messages=[{"role": "user", "content": user_msg}],
|
||||||
|
)
|
||||||
|
raw = msg.content[0].text.strip()
|
||||||
|
# Strip markdown fences if present
|
||||||
|
if raw.startswith("```"):
|
||||||
|
import re
|
||||||
|
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||||
|
raw = re.sub(r'\s*```$', '', raw.strip())
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
|
||||||
|
response_data.summary = parsed.get("summary")
|
||||||
|
response_data.description = parsed.get("description")
|
||||||
|
warnings = parsed.get("warnings", [])
|
||||||
|
|
||||||
|
# Resolve board_id
|
||||||
|
if parsed.get("board_id"):
|
||||||
|
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
|
||||||
|
if board_match:
|
||||||
|
response_data.board_id = board_match.id
|
||||||
|
else:
|
||||||
|
missing_fields.append("board_id")
|
||||||
|
warnings.append(f"Board ID {parsed['board_id']} not found")
|
||||||
|
else:
|
||||||
|
missing_fields.append("board_id")
|
||||||
|
|
||||||
|
# Resolve assignee
|
||||||
|
if parsed.get("assignee_identifier"):
|
||||||
|
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
|
||||||
|
if member:
|
||||||
|
response_data.assigned_member_id = int(member.id)
|
||||||
|
else:
|
||||||
|
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
|
||||||
|
|
||||||
|
# Priority/status/company always need manual selection
|
||||||
|
missing_fields.extend(["status_id", "priority_id", "company_id"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("AI parse failed: %s", e)
|
||||||
|
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
|
||||||
|
warnings = ["AI parsing failed — please fill in manually"]
|
||||||
|
|
||||||
|
response_data.missing_fields = missing_fields
|
||||||
|
response_data.warnings = warnings
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
|
||||||
|
async def update_ticket_status_endpoint(
|
||||||
|
ticket_id: int,
|
||||||
|
status_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Update a ticket's status."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
|
||||||
|
async def list_ticket_resources(
|
||||||
|
ticket_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
# Resources are optional display data — degrade gracefully rather than surfacing a toast
|
||||||
|
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
|
||||||
|
async def add_ticket_resource(
|
||||||
|
ticket_id: int,
|
||||||
|
member_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
|
||||||
|
async def remove_ticket_resource(
|
||||||
|
ticket_id: int,
|
||||||
|
member_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
|
||||||
|
except PSAError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/priorities", response_model=list[PSAPrioritySchema])
|
||||||
|
async def list_priorities(
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""List PSA priority levels for ticket creation form."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
raw = await provider.list_priorities()
|
||||||
|
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
|
||||||
|
except PSAError as e:
|
||||||
|
logger.warning("list_priorities failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tickets/{ticket_id}/context")
|
@router.get("/tickets/{ticket_id}/context")
|
||||||
async def get_ticket_context(
|
async def get_ticket_context(
|
||||||
ticket_id: int,
|
ticket_id: int,
|
||||||
@@ -561,7 +790,30 @@ async def get_ticket_statuses(
|
|||||||
except PSANotFoundError:
|
except PSANotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||||
except PSAError as e:
|
except PSAError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
|
||||||
|
async def get_board_statuses(
|
||||||
|
board_id: int,
|
||||||
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
):
|
||||||
|
"""Get available statuses for a service board directly (no ticket lookup required)."""
|
||||||
|
if not current_user.account_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
|
statuses = await provider.get_ticket_statuses(board_id)
|
||||||
|
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
|
||||||
|
except PSAError as e:
|
||||||
|
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── member mapping endpoints ─────────────────────────────────────────
|
# ── member mapping endpoints ─────────────────────────────────────────
|
||||||
@@ -569,7 +821,7 @@ async def get_ticket_statuses(
|
|||||||
|
|
||||||
@router.get("/members", response_model=list[PsaMemberResponse])
|
@router.get("/members", response_model=list[PsaMemberResponse])
|
||||||
async def list_members(
|
async def list_members(
|
||||||
current_user: Annotated[User, Depends(require_account_owner)],
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
"""List CW members (from CW API)."""
|
"""List CW members (from CW API)."""
|
||||||
@@ -587,7 +839,9 @@ async def list_members(
|
|||||||
for m in members
|
for m in members
|
||||||
]
|
]
|
||||||
except PSAError as e:
|
except PSAError as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
# Members are optional display data — degrade gracefully
|
||||||
|
logger.warning("list_members failed: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ from typing import Annotated
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
|
from app.models.ai_session import AISession
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.script_builder_session import ScriptBuilderSession
|
from app.models.script_builder_session import ScriptBuilderSession
|
||||||
from app.schemas.script_builder import (
|
from app.schemas.script_builder import (
|
||||||
@@ -67,15 +69,85 @@ async def create_session(
|
|||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
) -> ScriptBuilderSessionDetail:
|
) -> ScriptBuilderSessionDetail:
|
||||||
"""Start a new Script Builder session."""
|
"""Start a new Script Builder session.
|
||||||
|
|
||||||
|
When origin='pilot_inline', behaves as get-or-create: the same row is
|
||||||
|
returned on repeated calls with the same (user, ai_session_id) pair.
|
||||||
|
Inline sessions are excluded from the session cap and the list endpoint.
|
||||||
|
"""
|
||||||
|
# Phase 9: inline origin validation + authorization
|
||||||
|
if data.origin == "pilot_inline":
|
||||||
|
if data.ai_session_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="ai_session_id is required when origin='pilot_inline'",
|
||||||
|
)
|
||||||
|
# Ownership check: the pilot session must belong to the current user.
|
||||||
|
ai_session = await db.scalar(
|
||||||
|
select(AISession).where(
|
||||||
|
AISession.id == data.ai_session_id,
|
||||||
|
AISession.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if ai_session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Session not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Idempotent get-or-create: if a pilot_inline row already exists for
|
||||||
|
# this (user, ai_session_id) pair, return it without creating a duplicate.
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(ScriptBuilderSession).where(
|
||||||
|
ScriptBuilderSession.user_id == current_user.id,
|
||||||
|
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||||
|
ScriptBuilderSession.origin == "pilot_inline",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
# Re-fetch with message_records loaded
|
||||||
|
session = await script_builder_service.get_session(db, existing.id, current_user.id)
|
||||||
|
return _session_to_detail(session)
|
||||||
|
|
||||||
|
# Create the inline session — wrap in IntegrityError catch for races.
|
||||||
|
try:
|
||||||
|
session = await script_builder_service.create_session(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
team_id=current_user.team_id,
|
||||||
|
language=data.language,
|
||||||
|
origin=data.origin,
|
||||||
|
ai_session_id=data.ai_session_id,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
# Race: another request won the unique index — re-read the winner row.
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(ScriptBuilderSession).where(
|
||||||
|
ScriptBuilderSession.user_id == current_user.id,
|
||||||
|
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||||
|
ScriptBuilderSession.origin == "pilot_inline",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
raise
|
||||||
|
session = existing
|
||||||
|
|
||||||
|
# Re-fetch with message_records loaded
|
||||||
|
session = await script_builder_service.get_session(db, session.id, current_user.id)
|
||||||
|
return _session_to_detail(session)
|
||||||
|
|
||||||
|
# ── Standalone session ──────────────────────────────────────────────────
|
||||||
# Acquire per-user advisory lock so concurrent create requests are serialized.
|
# Acquire per-user advisory lock so concurrent create requests are serialized.
|
||||||
# Without this, two simultaneous requests both read count < limit and both
|
# Without this, two simultaneous requests both read count < limit and both
|
||||||
# insert, exceeding MAX_SESSIONS_PER_USER.
|
# insert, exceeding MAX_SESSIONS_PER_USER.
|
||||||
user_lock_key = hash(str(current_user.id)) % (2**62)
|
user_lock_key = hash(str(current_user.id)) % (2**62)
|
||||||
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
|
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
|
||||||
|
|
||||||
# Enforce max concurrent sessions
|
# Enforce max concurrent sessions (inline sessions excluded from cap)
|
||||||
count = await script_builder_service.count_user_sessions(db, current_user.id)
|
count = await script_builder_service.count_user_sessions(db, current_user.id, include_inline=False)
|
||||||
if count >= MAX_SESSIONS_PER_USER:
|
if count >= MAX_SESSIONS_PER_USER:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -88,6 +160,8 @@ async def create_session(
|
|||||||
account_id=current_user.account_id,
|
account_id=current_user.account_id,
|
||||||
team_id=current_user.team_id,
|
team_id=current_user.team_id,
|
||||||
language=data.language,
|
language=data.language,
|
||||||
|
origin=data.origin,
|
||||||
|
ai_session_id=data.ai_session_id,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
# Re-fetch with message_records loaded
|
# Re-fetch with message_records loaded
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_, literal
|
from sqlalchemy import select, func, or_, literal, update as sa_update
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
@@ -374,6 +374,20 @@ async def generate_script(
|
|||||||
)
|
)
|
||||||
db.add(generation)
|
db.add(generation)
|
||||||
template.usage_count += 1
|
template.usage_count += 1
|
||||||
|
|
||||||
|
# FlowPilot Phase 3: bump the linked AI session's state_version so the
|
||||||
|
# resolution-note preview cache invalidates. One-off scripts run outside
|
||||||
|
# any FlowPilot session — in that case the UPDATE matches zero rows.
|
||||||
|
if data.ai_session_id is not None:
|
||||||
|
# Local import: scripts endpoint stays independent of AI-session
|
||||||
|
# imports for non-AI generation paths.
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
await db.execute(
|
||||||
|
sa_update(AISession)
|
||||||
|
.where(AISession.id == data.ai_session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(generation)
|
await db.refresh(generation)
|
||||||
|
|
||||||
|
|||||||
315
backend/app/api/endpoints/session_facts.py
Normal file
315
backend/app/api/endpoints/session_facts.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
"""Session fact endpoints — the "What we know" CRUD surface for a FlowPilot session.
|
||||||
|
|
||||||
|
All routes are sub-resources of `/ai-sessions/{session_id}`. Tenant isolation is
|
||||||
|
enforced by RLS on `session_facts.account_id`; a user from another account
|
||||||
|
literally cannot see or write facts for this session.
|
||||||
|
|
||||||
|
Editability rule (per FLOWPILOT-MIGRATION.md Section 7.3):
|
||||||
|
- `user_note` and `ai_synthesis` facts are editable at the card level.
|
||||||
|
- `question` and `diagnostic_check` facts are read-only at the card level —
|
||||||
|
edit the source question/check instead. PATCH returns 403 for those.
|
||||||
|
|
||||||
|
Fact promotion writes always bump `ai_sessions.state_version` so the
|
||||||
|
resolution-note preview cache invalidates (Section 5.5).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
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, require_engineer_or_admin
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.session_fact import (
|
||||||
|
SessionFactCreateRequest,
|
||||||
|
SessionFactListResponse,
|
||||||
|
SessionFactPromoteRequest,
|
||||||
|
SessionFactResponse,
|
||||||
|
SessionFactUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services.fact_synthesis_service import (
|
||||||
|
FactSynthesisService,
|
||||||
|
list_facts_for_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-facts"])
|
||||||
|
|
||||||
|
# Source types whose facts can be edited at the card level (Section 7.3).
|
||||||
|
_EDITABLE_SOURCE_TYPES = frozenset({"user_note", "ai_synthesis"})
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(fact: SessionFact) -> SessionFactResponse:
|
||||||
|
"""Wrap an ORM SessionFact in the response model with the editable flag."""
|
||||||
|
return SessionFactResponse(
|
||||||
|
id=fact.id,
|
||||||
|
session_id=fact.session_id,
|
||||||
|
text=fact.text,
|
||||||
|
source_type=fact.source_type, # type: ignore[arg-type]
|
||||||
|
source_ref=fact.source_ref,
|
||||||
|
source_summary=fact.source_summary,
|
||||||
|
created_by=fact.created_by,
|
||||||
|
created_at=fact.created_at,
|
||||||
|
updated_at=fact.updated_at,
|
||||||
|
editable=fact.source_type in _EDITABLE_SOURCE_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
|
||||||
|
"""Load the session via RLS-scoped SELECT. Returns 404 if missing/cross-tenant.
|
||||||
|
|
||||||
|
Tenant isolation: RLS on `ai_sessions` filters by current account, so a
|
||||||
|
cross-tenant access returns no rows and we 404 (rather than 403, which
|
||||||
|
would leak the row's existence).
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(AISession).where(AISession.id == session_id))
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_fact_or_404(
|
||||||
|
db: AsyncSession, session_id: UUID, fact_id: UUID
|
||||||
|
) -> SessionFact:
|
||||||
|
"""Load a non-deleted fact for the session. 404 if missing or already deleted."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionFact).where(
|
||||||
|
SessionFact.id == fact_id,
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fact = result.scalar_one_or_none()
|
||||||
|
if fact is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fact not found")
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
|
# ── List ──
|
||||||
|
|
||||||
|
@router.get("/facts", response_model=SessionFactListResponse)
|
||||||
|
async def list_facts(
|
||||||
|
session_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionFactListResponse:
|
||||||
|
"""List facts for a session, oldest first."""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
facts = await list_facts_for_session(db, session_id)
|
||||||
|
return SessionFactListResponse(facts=[_to_response(f) for f in facts])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Create (manual user note) ──
|
||||||
|
|
||||||
|
@router.post("/facts", response_model=SessionFactResponse, status_code=201)
|
||||||
|
async def create_fact(
|
||||||
|
session_id: UUID,
|
||||||
|
body: SessionFactCreateRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionFactResponse:
|
||||||
|
"""Create a manual fact (the "+ Add a note" UI affordance).
|
||||||
|
|
||||||
|
Always recorded as `source_type=user_note`. Source-typed creation goes
|
||||||
|
through `/facts/promote` so the originating item ID is captured.
|
||||||
|
"""
|
||||||
|
session = await _load_session_or_404(db, session_id)
|
||||||
|
service = FactSynthesisService(db)
|
||||||
|
try:
|
||||||
|
fact = await service.create_fact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
source_type="user_note",
|
||||||
|
text=body.text,
|
||||||
|
summary=body.summary,
|
||||||
|
source_ref=None,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fact)
|
||||||
|
return _to_response(fact)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update ──
|
||||||
|
|
||||||
|
@router.patch("/facts/{fact_id}", response_model=SessionFactResponse)
|
||||||
|
async def update_fact(
|
||||||
|
session_id: UUID,
|
||||||
|
fact_id: UUID,
|
||||||
|
body: SessionFactUpdateRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionFactResponse:
|
||||||
|
"""Edit fact text or summary.
|
||||||
|
|
||||||
|
Returns 403 for `question` and `diagnostic_check`-sourced facts: the
|
||||||
|
source item is the canonical input, so editing the fact card would
|
||||||
|
desync the two. Engineers edit the source instead.
|
||||||
|
"""
|
||||||
|
fact = await _load_fact_or_404(db, session_id, fact_id)
|
||||||
|
if fact.source_type not in _EDITABLE_SOURCE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=(
|
||||||
|
f"Facts sourced from {fact.source_type!r} are read-only at the "
|
||||||
|
"card level. Edit the originating question or diagnostic check instead."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.text is None and body.summary is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="At least one of `text` or `summary` must be provided",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = FactSynthesisService(db)
|
||||||
|
try:
|
||||||
|
fact = await service.update_fact(fact, text=body.text, summary=body.summary)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fact)
|
||||||
|
return _to_response(fact)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Soft delete ──
|
||||||
|
|
||||||
|
@router.delete("/facts/{fact_id}", status_code=204)
|
||||||
|
async def delete_fact(
|
||||||
|
session_id: UUID,
|
||||||
|
fact_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Soft-delete a fact. All source types are deletable.
|
||||||
|
|
||||||
|
Soft delete (rather than hard) preserves provenance for audit and lets
|
||||||
|
accidental deletes be recovered if needed. The `editable` flag does NOT
|
||||||
|
control deletion — even read-only facts can be removed when the
|
||||||
|
underlying question/check turned out to be wrong.
|
||||||
|
"""
|
||||||
|
fact = await _load_fact_or_404(db, session_id, fact_id)
|
||||||
|
service = FactSynthesisService(db)
|
||||||
|
await service.soft_delete_fact(fact)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Promote (AI marker + engineer-driven) ──
|
||||||
|
|
||||||
|
@router.post("/facts/promote", response_model=SessionFactResponse, status_code=201)
|
||||||
|
async def promote_fact(
|
||||||
|
session_id: UUID,
|
||||||
|
body: SessionFactPromoteRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionFactResponse:
|
||||||
|
"""Convert a question answer / check result into a fact.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
|
||||||
|
- `proposed_text` provided → persisted as-is.
|
||||||
|
- `raw_input` provided → server drafts text/summary via FactSynthesisService.
|
||||||
|
|
||||||
|
Exactly one of the two must be set. The engineer-facing UI typically uses
|
||||||
|
`proposed_text` after letting the engineer review/edit a draft.
|
||||||
|
"""
|
||||||
|
if (body.proposed_text is None) == (body.raw_input is None):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Exactly one of `proposed_text` or `raw_input` must be provided",
|
||||||
|
)
|
||||||
|
if body.source_type == "ai_synthesis" and body.source_ref is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="`source_ref` must be null for source_type=ai_synthesis",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = await _load_session_or_404(db, session_id)
|
||||||
|
service = FactSynthesisService(db)
|
||||||
|
|
||||||
|
text = body.proposed_text
|
||||||
|
summary = body.proposed_summary
|
||||||
|
if text is None:
|
||||||
|
# Synthesize via LLM. Caller must hint which task-lane item the input
|
||||||
|
# came from so we can shape the prompt appropriately.
|
||||||
|
raw = body.raw_input or ""
|
||||||
|
if body.source_type == "question":
|
||||||
|
draft = await service.synthesize_from_question(
|
||||||
|
question_text=_lookup_task_lane_text(session, body.source_ref, "questions"),
|
||||||
|
raw_answer=raw,
|
||||||
|
)
|
||||||
|
elif body.source_type == "diagnostic_check":
|
||||||
|
draft = await service.synthesize_from_check(
|
||||||
|
check_label=_lookup_task_lane_text(session, body.source_ref, "actions"),
|
||||||
|
check_output=raw,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# ai_synthesis with raw_input: the raw input IS the synthesis.
|
||||||
|
# Re-run through the question synthesizer with an empty question
|
||||||
|
# so the conservative prompt still applies.
|
||||||
|
draft = await service.synthesize_from_question(
|
||||||
|
question_text="(none — synthesizing from engineer summary)",
|
||||||
|
raw_answer=raw,
|
||||||
|
)
|
||||||
|
text = draft["text"]
|
||||||
|
summary = summary or draft["summary"]
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=(
|
||||||
|
"Synthesizer found no substantive fact in the input. "
|
||||||
|
"Edit the input or supply `proposed_text` directly."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fact = await service.create_fact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
source_type=body.source_type,
|
||||||
|
text=text,
|
||||||
|
summary=summary,
|
||||||
|
source_ref=body.source_ref,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fact)
|
||||||
|
return _to_response(fact)
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_task_lane_text(
|
||||||
|
session: AISession, source_ref: UUID | None, list_key: str
|
||||||
|
) -> str:
|
||||||
|
"""Find the originating question text / action label from pending_task_lane.
|
||||||
|
|
||||||
|
Falls back to a generic placeholder if the source item is no longer in
|
||||||
|
the lane (e.g., the AI dropped it from a later turn). The synthesizer is
|
||||||
|
forgiving — an empty/generic question still produces a useful fact when
|
||||||
|
the engineer's answer is substantive on its own.
|
||||||
|
"""
|
||||||
|
if source_ref is None:
|
||||||
|
return ""
|
||||||
|
lane = session.pending_task_lane or {}
|
||||||
|
items = lane.get(list_key) or []
|
||||||
|
sref = str(source_ref)
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict) and str(item.get("id")) == sref:
|
||||||
|
return str(item.get("text") or item.get("label") or "")
|
||||||
|
return ""
|
||||||
759
backend/app/api/endpoints/session_suggested_fixes.py
Normal file
759
backend/app/api/endpoints/session_suggested_fixes.py
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
"""Suggested-fix + resolution-note / escalation-package preview-and-post endpoints.
|
||||||
|
|
||||||
|
Phase 3: active suggested fix lookup + decision recording, resolution-note
|
||||||
|
preview with state_version cache.
|
||||||
|
|
||||||
|
Phase 4: resolution-note POST (writeback to PSA + mark resolved), escalation
|
||||||
|
package preview + POST (writeback + mark escalated). Local-only path when
|
||||||
|
the session has no linked PSA ticket: markdown is stored on the session and
|
||||||
|
the status flipped, no external call.
|
||||||
|
|
||||||
|
Per FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.session_suggested_fix import (
|
||||||
|
EscalationPackagePostRequest,
|
||||||
|
ResolutionNotePostRequest,
|
||||||
|
ResolutionNotePreviewResponse,
|
||||||
|
ResolutionPostResponse,
|
||||||
|
SessionSuggestedFixDecisionRequest,
|
||||||
|
SessionSuggestedFixDecisionResponse,
|
||||||
|
SessionSuggestedFixOutcomeRequest,
|
||||||
|
SessionSuggestedFixResponse,
|
||||||
|
SessionSuggestedFixScriptRequest,
|
||||||
|
)
|
||||||
|
from app.models.draft_template import DraftTemplate
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
from app.services.escalation_package_generator import EscalationPackageGeneratorService
|
||||||
|
from app.services.preview_cache import preview_cache
|
||||||
|
from app.services.psa_writeback_service import (
|
||||||
|
PSAStatusVerificationError,
|
||||||
|
PSAWritebackService,
|
||||||
|
)
|
||||||
|
from app.services.resolution_note_generator import ResolutionNoteGeneratorService
|
||||||
|
from app.services.template_extraction_service import extract_parameters as _extract_template_parameters
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-suggested-fixes"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_session_or_404(db: AsyncSession, session_id: UUID) -> AISession:
|
||||||
|
"""RLS-scoped session load. 404 covers both missing and cross-tenant."""
|
||||||
|
result = await db.execute(select(AISession).where(AISession.id == session_id))
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suggested fix: active ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/suggested-fixes/active",
|
||||||
|
response_model=SessionSuggestedFixResponse,
|
||||||
|
)
|
||||||
|
async def get_active_suggested_fix(
|
||||||
|
session_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionSuggestedFixResponse:
|
||||||
|
"""Return the current active suggested fix (`superseded_at IS NULL`) or 404.
|
||||||
|
|
||||||
|
A session has at most one active fix. Multiple historical rows persist
|
||||||
|
for audit, but only the most-recent un-superseded one is returned here.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionSuggestedFix.created_at.desc())
|
||||||
|
)
|
||||||
|
fix = result.scalars().first()
|
||||||
|
if fix is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No active suggested fix for this session",
|
||||||
|
)
|
||||||
|
return SessionSuggestedFixResponse.model_validate(fix)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suggested fix: decision ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/suggested-fixes/{fix_id}/decision",
|
||||||
|
response_model=SessionSuggestedFixDecisionResponse,
|
||||||
|
)
|
||||||
|
async def record_decision(
|
||||||
|
session_id: UUID,
|
||||||
|
fix_id: UUID,
|
||||||
|
body: SessionSuggestedFixDecisionRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionSuggestedFixDecisionResponse:
|
||||||
|
"""Record the engineer's path choice on a suggested fix.
|
||||||
|
|
||||||
|
Phase 3 recorded the choice and (for `dismissed`) superseded the fix.
|
||||||
|
Phase 5 adds side effects: one_off / draft_template return the rendered
|
||||||
|
script; draft_template also creates a `draft_templates` row via the
|
||||||
|
TemplateExtractionService; build_template returns a redirect to the
|
||||||
|
Script Builder.
|
||||||
|
"""
|
||||||
|
session_obj = await _load_session_or_404(db, session_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionSuggestedFix).where(
|
||||||
|
SessionSuggestedFix.id == fix_id,
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fix = result.scalar_one_or_none()
|
||||||
|
if fix is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once a fix has been superseded we still record the engineer's
|
||||||
|
# decision (it's a historical signal — "engineer dismissed the
|
||||||
|
# interim hypothesis"), but `dismissed` on a superseded row would
|
||||||
|
# be redundant noise.
|
||||||
|
if fix.superseded_at is not None and body.decision == "dismissed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="This fix is already superseded by a newer suggestion",
|
||||||
|
)
|
||||||
|
|
||||||
|
fix.user_decision = body.decision
|
||||||
|
if body.decision == "dismissed" and fix.superseded_at is None:
|
||||||
|
fix.superseded_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Engineer's choice changes the bundle the resolution-note preview sees,
|
||||||
|
# so bump state_version too.
|
||||||
|
await db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered_script: str | None = None
|
||||||
|
draft_template_id: UUID | None = None
|
||||||
|
redirect_path: str | None = None
|
||||||
|
|
||||||
|
# Phase 5 side effects. All three non-dismiss paths assume the fix has
|
||||||
|
# either a script_template_id (template match — use the dedicated
|
||||||
|
# /scripts/generate endpoint from the frontend, not this one) or an
|
||||||
|
# ai_drafted_script (custom script — this is the entry point).
|
||||||
|
if body.decision in ("one_off", "draft_template", "build_template"):
|
||||||
|
drafted = body.edited_script or fix.ai_drafted_script
|
||||||
|
if not drafted:
|
||||||
|
# Template-matched fixes take the regular /scripts/generate path.
|
||||||
|
# If a fix somehow reaches here without a drafted script AND
|
||||||
|
# without a template, that's a client-side wiring bug.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Suggested fix has no ai_drafted_script — use "
|
||||||
|
"/api/v1/scripts/generate for template-matched fixes."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rendered_script = drafted.strip()
|
||||||
|
|
||||||
|
if body.decision == "draft_template":
|
||||||
|
# TemplateExtractionService proposes the parameterization. Runs
|
||||||
|
# under the same transaction so a failure rolls back the decision.
|
||||||
|
session_ctx = await _summarize_session_for_extraction(db, session_id)
|
||||||
|
extraction = await _extract_template_parameters(
|
||||||
|
script_body=rendered_script or "",
|
||||||
|
session_context=session_ctx,
|
||||||
|
ticket_context=None, # ticket context wiring lands in Phase 5 polish
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = DraftTemplate(
|
||||||
|
account_id=session_obj.account_id,
|
||||||
|
source_session_id=session_obj.id,
|
||||||
|
source_user_id=current_user.id,
|
||||||
|
script_body=extraction["templated_body"] or (rendered_script or ""),
|
||||||
|
proposed_parameters={"parameters": extraction["parameters"]},
|
||||||
|
proposed_name=fix.title[:200] if fix.title else None,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(draft)
|
||||||
|
await db.flush()
|
||||||
|
draft_template_id = draft.id
|
||||||
|
|
||||||
|
if body.decision == "build_template":
|
||||||
|
# Frontend navigates to the Script Builder preloaded with the
|
||||||
|
# drafted body. The builder wires the full parameterization flow;
|
||||||
|
# we hand it a scratch-pad query string, not persistent state.
|
||||||
|
redirect_path = (
|
||||||
|
f"/scripts/builder?from_session={session_obj.id}&fix={fix.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fix)
|
||||||
|
|
||||||
|
return SessionSuggestedFixDecisionResponse(
|
||||||
|
id=fix.id,
|
||||||
|
user_decision=fix.user_decision, # type: ignore[arg-type]
|
||||||
|
rendered_script=rendered_script,
|
||||||
|
draft_template_id=draft_template_id,
|
||||||
|
redirect_path=redirect_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suggested fix: apply (stamp applied_at) ──────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/suggested-fixes/{fix_id}/apply",
|
||||||
|
response_model=SessionSuggestedFixResponse,
|
||||||
|
)
|
||||||
|
async def apply_suggested_fix(
|
||||||
|
session_id: UUID,
|
||||||
|
fix_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionSuggestedFixResponse:
|
||||||
|
"""Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
|
||||||
|
|
||||||
|
This does NOT change status (fix remains 'proposed'). Status only flips
|
||||||
|
when the engineer records an outcome via PATCH /outcome.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Fix must be in 'proposed' status; any other status → 409.
|
||||||
|
- Idempotent: if applied_at is already set, returns 200 with the unchanged row.
|
||||||
|
- Bumps ai_sessions.state_version so resolve/escalate preview generators
|
||||||
|
know the fix has entered the verifying phase.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionSuggestedFix).where(
|
||||||
|
SessionSuggestedFix.id == fix_id,
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fix = result.scalar_one_or_none()
|
||||||
|
if fix is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if fix.status != "proposed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Apply is only valid from 'proposed'; fix is already '{fix.status}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Idempotent: already stamped → return as-is without bumping state_version again.
|
||||||
|
if fix.applied_at is not None:
|
||||||
|
return SessionSuggestedFixResponse.model_validate(fix)
|
||||||
|
|
||||||
|
fix.applied_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Bump state_version so preview generators see the verifying-phase signal.
|
||||||
|
await db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fix)
|
||||||
|
return SessionSuggestedFixResponse.model_validate(fix)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suggested fix: outcome ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/suggested-fixes/{fix_id}/outcome",
|
||||||
|
response_model=SessionSuggestedFixResponse,
|
||||||
|
)
|
||||||
|
async def patch_suggested_fix_outcome(
|
||||||
|
session_id: UUID,
|
||||||
|
fix_id: UUID,
|
||||||
|
body: SessionSuggestedFixOutcomeRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionSuggestedFixResponse:
|
||||||
|
"""Record the engineer's outcome for an applied fix.
|
||||||
|
|
||||||
|
See `SessionSuggestedFixOutcomeRequest` for transition rules.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionSuggestedFix).where(
|
||||||
|
SessionSuggestedFix.id == fix_id,
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fix = result.scalar_one_or_none()
|
||||||
|
if fix is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="notes are required when outcome is applied_partial",
|
||||||
|
)
|
||||||
|
|
||||||
|
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||||
|
if fix.status in TERMINAL:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Fix is already in terminal status {fix.status!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
fix.status = body.outcome
|
||||||
|
if body.outcome == "applied_partial":
|
||||||
|
fix.partial_notes = (body.notes or "").strip() or None
|
||||||
|
elif body.outcome == "applied_failed":
|
||||||
|
fix.failure_reason = (body.notes or "").strip() or None
|
||||||
|
fix.verified_at = now
|
||||||
|
elif body.outcome == "applied_success":
|
||||||
|
fix.verified_at = now
|
||||||
|
# dismissed: no timestamp/notes stamping
|
||||||
|
|
||||||
|
if fix.applied_at is None and body.outcome != "dismissed":
|
||||||
|
fix.applied_at = now
|
||||||
|
|
||||||
|
# Clear any pending AI outcome proposal — engineer has taken a terminal action.
|
||||||
|
fix.ai_outcome_proposal = None
|
||||||
|
|
||||||
|
# Outcome changes the bundle that resolution-note/escalation-package
|
||||||
|
# previews see, so bump state_version inside the same transaction —
|
||||||
|
# mirrors the pattern in record_decision above.
|
||||||
|
await db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fix)
|
||||||
|
return SessionSuggestedFixResponse.model_validate(fix)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suggested fix: attach drafted script ─────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/suggested-fixes/{fix_id}/script",
|
||||||
|
response_model=SessionSuggestedFixResponse,
|
||||||
|
)
|
||||||
|
async def patch_suggested_fix_script(
|
||||||
|
session_id: UUID,
|
||||||
|
fix_id: UUID,
|
||||||
|
body: SessionSuggestedFixScriptRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionSuggestedFixResponse:
|
||||||
|
"""Attach an engineer-drafted script to a suggested fix.
|
||||||
|
|
||||||
|
Called by the inline Script Builder tab on Submit. Does NOT stamp
|
||||||
|
applied_at — a draft is not an application. Bumps state_version so
|
||||||
|
the Resolve/Escalate preview bundles regenerate.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
|
||||||
|
fix = await db.scalar(
|
||||||
|
select(SessionSuggestedFix).where(
|
||||||
|
SessionSuggestedFix.id == fix_id,
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if fix is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found")
|
||||||
|
|
||||||
|
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||||
|
if fix.status in TERMINAL:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Fix is already in terminal status {fix.status!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
fix.ai_drafted_script = body.ai_drafted_script
|
||||||
|
fix.ai_drafted_parameters = body.ai_drafted_parameters
|
||||||
|
|
||||||
|
# Bump state_version on the parent session — previews cached by
|
||||||
|
# (session_id, state_version) must regenerate to reflect the new draft.
|
||||||
|
await db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fix)
|
||||||
|
return SessionSuggestedFixResponse.model_validate(fix)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suggested fix: clear AI outcome proposal ("Not yet") ─────────────────────
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||||
|
response_model=SessionSuggestedFixResponse,
|
||||||
|
)
|
||||||
|
async def clear_ai_outcome_proposal(
|
||||||
|
session_id: UUID,
|
||||||
|
fix_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> SessionSuggestedFixResponse:
|
||||||
|
"""Explicitly dismiss the AI-proposed outcome banner ("Not yet").
|
||||||
|
|
||||||
|
Clears `ai_outcome_proposal` without touching status or state_version
|
||||||
|
(this is pure UI state, not outcome data). Idempotent: returns 200 even
|
||||||
|
when the field is already null. After this call the banner will not
|
||||||
|
re-surface on the next refreshSessionDerived unless the AI emits a new
|
||||||
|
proposal.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionSuggestedFix).where(
|
||||||
|
SessionSuggestedFix.id == fix_id,
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fix = result.scalar_one_or_none()
|
||||||
|
if fix is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
fix.ai_outcome_proposal = None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(fix)
|
||||||
|
return SessionSuggestedFixResponse.model_validate(fix)
|
||||||
|
|
||||||
|
|
||||||
|
async def _summarize_session_for_extraction(
|
||||||
|
db: AsyncSession, session_id: UUID,
|
||||||
|
) -> str:
|
||||||
|
"""Compact fact list for TemplateExtractionService context.
|
||||||
|
|
||||||
|
We don't send the full chat transcript — the extractor only needs enough
|
||||||
|
signal to decide which values in the script are session-specific (and
|
||||||
|
therefore worth parameterizing).
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionFact)
|
||||||
|
.where(
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionFact.created_at.asc())
|
||||||
|
)
|
||||||
|
facts = list(result.scalars().all())
|
||||||
|
if not facts:
|
||||||
|
return ""
|
||||||
|
lines = [f"- {f.text}" for f in facts]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolution note preview ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/resolution-note/preview",
|
||||||
|
response_model=ResolutionNotePreviewResponse,
|
||||||
|
)
|
||||||
|
async def resolution_note_preview(
|
||||||
|
session_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> ResolutionNotePreviewResponse:
|
||||||
|
"""Generate (or return cached) draft markdown for the Resolve note.
|
||||||
|
|
||||||
|
Cache key: `(resolution_note, session_id, state_version)`. State_version is
|
||||||
|
bumped by every fact / suggested-fix / script-generation write, so two
|
||||||
|
consecutive calls with no intervening writes return the same cached
|
||||||
|
payload (and won't pay for a Sonnet call).
|
||||||
|
|
||||||
|
Posted to PSA in Phase 4. Until then, this endpoint is read-only.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
gen = ResolutionNoteGeneratorService(db)
|
||||||
|
try:
|
||||||
|
payload = await gen.generate_or_get_cached(session_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Resolution note preview failed for session %s", session_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Resolution-note generator error ({type(e).__name__})",
|
||||||
|
)
|
||||||
|
return ResolutionNotePreviewResponse(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 4: escalation-package preview ────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/escalation-package/preview",
|
||||||
|
response_model=ResolutionNotePreviewResponse,
|
||||||
|
)
|
||||||
|
async def escalation_package_preview(
|
||||||
|
session_id: UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> ResolutionNotePreviewResponse:
|
||||||
|
"""Generate (or return cached) draft markdown for the Escalate handoff package.
|
||||||
|
|
||||||
|
Same caching story as the resolution-note preview: keyed on
|
||||||
|
`(session_id, state_version)`. Separate cache kind so a Resolve preview
|
||||||
|
and an Escalate preview for the same state can coexist.
|
||||||
|
"""
|
||||||
|
await _load_session_or_404(db, session_id)
|
||||||
|
gen = EscalationPackageGeneratorService(db)
|
||||||
|
try:
|
||||||
|
payload = await gen.generate_or_get_cached(session_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Escalation package preview failed for session %s", session_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Escalation-package generator error ({type(e).__name__})",
|
||||||
|
)
|
||||||
|
return ResolutionNotePreviewResponse(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 4: Resolve & post ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/resolution-note/post",
|
||||||
|
response_model=ResolutionPostResponse,
|
||||||
|
)
|
||||||
|
async def post_resolution_note(
|
||||||
|
session_id: UUID,
|
||||||
|
body: ResolutionNotePostRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> ResolutionPostResponse:
|
||||||
|
"""Commit the engineer-edited resolution note and close the session.
|
||||||
|
|
||||||
|
Three outcomes:
|
||||||
|
- **External post + status verified** — session.status='resolved',
|
||||||
|
markdown + external_id + posted_at persisted, CW status flipped to
|
||||||
|
the configured Resolved status ID and re-fetch-verified.
|
||||||
|
- **External post only** — markdown posted, but no cw_resolved_status_id
|
||||||
|
configured → session.status='resolved', `status_transition_skipped_reason`
|
||||||
|
explains the skip. Not an error — posting the note is meaningful.
|
||||||
|
- **Local-only** — session has no linked PSA ticket → markdown stored on
|
||||||
|
`resolution_note_markdown`, session.status='resolved', outcome =
|
||||||
|
'resolved_local'. No external call.
|
||||||
|
|
||||||
|
Status verification failure raises 502: the engineer intended to close
|
||||||
|
the ticket but we cannot confirm it actually closed. Surfacing silent
|
||||||
|
success would be a footgun.
|
||||||
|
"""
|
||||||
|
session_obj = await _load_session_or_404(db, session_id)
|
||||||
|
if session_obj.status not in ("active", "paused", "requesting_escalation", "escalated"):
|
||||||
|
# Already-resolved sessions shouldn't be re-posted; caller should
|
||||||
|
# query first. escalated→resolved is allowed (engineer revised course).
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Session is already {session_obj.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = PSAWritebackService(db)
|
||||||
|
summary = (body.resolution_summary or body.markdown.strip().splitlines()[0])[:500]
|
||||||
|
|
||||||
|
# Local-only path — no PSA ticket linked, nothing to post.
|
||||||
|
if not session_obj.psa_ticket_id or not session_obj.psa_connection_id:
|
||||||
|
session_obj.resolution_note_markdown = body.markdown.strip()
|
||||||
|
session_obj.status = "resolved"
|
||||||
|
session_obj.resolved_at = datetime.now(timezone.utc)
|
||||||
|
session_obj.resolution_summary = summary
|
||||||
|
await db.commit()
|
||||||
|
return ResolutionPostResponse(
|
||||||
|
outcome="resolved_local",
|
||||||
|
session_status=session_obj.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
posted = await service.post_resolution_note(session_obj, body.markdown)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("post_resolution_note failed for session %s", session_id)
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"PSA post failed ({type(e).__name__})",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt the status transition if configured; failed verification is
|
||||||
|
# surfaced loudly (status_code 502) per the ConnectWise anti-silent-
|
||||||
|
# success principle. Not configured → skip with a reason, not an error.
|
||||||
|
target_status_id = await service.resolved_status_id_for_account(session_obj.account_id)
|
||||||
|
verified_status_id: int | None = None
|
||||||
|
verified_status_name: str | None = None
|
||||||
|
skipped_reason: str | None = None
|
||||||
|
if target_status_id is None:
|
||||||
|
skipped_reason = (
|
||||||
|
"No cw_resolved_status_id configured in account_settings.preferences — "
|
||||||
|
"note posted, status unchanged."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = await service.transition_ticket_status(session_obj, target_status_id)
|
||||||
|
verified_status_id = result["verified_status_id"]
|
||||||
|
verified_status_name = result["verified_status_name"]
|
||||||
|
except PSAStatusVerificationError as e:
|
||||||
|
logger.error("Status verification failed for session %s: %s", session_id, e)
|
||||||
|
# Note was already posted — roll that partial side effect back in
|
||||||
|
# the session record (the CW note itself can't be un-posted).
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Status transition failed for session %s", session_id)
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"PSA status transition error ({type(e).__name__})",
|
||||||
|
)
|
||||||
|
|
||||||
|
session_obj.status = "resolved"
|
||||||
|
session_obj.resolved_at = datetime.now(timezone.utc)
|
||||||
|
session_obj.resolution_summary = summary
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResolutionPostResponse(
|
||||||
|
outcome="resolved",
|
||||||
|
session_status=session_obj.status,
|
||||||
|
external_id=posted["external_id"],
|
||||||
|
posted_at=posted["posted_at"],
|
||||||
|
verified_status_id=verified_status_id,
|
||||||
|
verified_status_name=verified_status_name,
|
||||||
|
status_transition_skipped_reason=skipped_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 4: Escalate & post ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/escalation-package/post",
|
||||||
|
response_model=ResolutionPostResponse,
|
||||||
|
)
|
||||||
|
async def post_escalation_package(
|
||||||
|
session_id: UUID,
|
||||||
|
body: EscalationPackagePostRequest,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
_: None = Depends(require_engineer_or_admin),
|
||||||
|
) -> ResolutionPostResponse:
|
||||||
|
"""Commit the engineer-edited escalation package and mark the session escalated.
|
||||||
|
|
||||||
|
Structure mirrors post_resolution_note:
|
||||||
|
- Local-only when no PSA ticket: markdown stored, session.status='escalated'.
|
||||||
|
- PSA post: internal-analysis note (handoff is for the next engineer,
|
||||||
|
not the customer), optional status transition via cw_escalated_status_id,
|
||||||
|
re-fetch verified.
|
||||||
|
"""
|
||||||
|
session_obj = await _load_session_or_404(db, session_id)
|
||||||
|
if session_obj.status not in ("active", "paused", "resolved"):
|
||||||
|
# resolved→escalated is allowed (engineer realized they need help
|
||||||
|
# after closing); escalated→escalated would be a no-op, block it.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Session is already {session_obj.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
service = PSAWritebackService(db)
|
||||||
|
reason = body.escalation_reason or body.markdown.strip().splitlines()[0][:500]
|
||||||
|
|
||||||
|
if not session_obj.psa_ticket_id or not session_obj.psa_connection_id:
|
||||||
|
session_obj.escalation_package_markdown = body.markdown.strip()
|
||||||
|
session_obj.status = "escalated"
|
||||||
|
session_obj.escalation_reason = reason
|
||||||
|
await db.commit()
|
||||||
|
return ResolutionPostResponse(
|
||||||
|
outcome="escalated_local",
|
||||||
|
session_status=session_obj.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
posted = await service.post_escalation_package(session_obj, body.markdown)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("post_escalation_package failed for session %s", session_id)
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"PSA post failed ({type(e).__name__})",
|
||||||
|
)
|
||||||
|
|
||||||
|
target_status_id = await service.escalated_status_id_for_account(session_obj.account_id)
|
||||||
|
verified_status_id: int | None = None
|
||||||
|
verified_status_name: str | None = None
|
||||||
|
skipped_reason: str | None = None
|
||||||
|
if target_status_id is None:
|
||||||
|
skipped_reason = (
|
||||||
|
"No cw_escalated_status_id configured — package posted, status unchanged."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = await service.transition_ticket_status(session_obj, target_status_id)
|
||||||
|
verified_status_id = result["verified_status_id"]
|
||||||
|
verified_status_name = result["verified_status_name"]
|
||||||
|
except PSAStatusVerificationError as e:
|
||||||
|
logger.error("Status verification failed for session %s: %s", session_id, e)
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Status transition failed for session %s", session_id)
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"PSA status transition error ({type(e).__name__})",
|
||||||
|
)
|
||||||
|
|
||||||
|
session_obj.status = "escalated"
|
||||||
|
session_obj.escalation_reason = reason
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ResolutionPostResponse(
|
||||||
|
outcome="escalated",
|
||||||
|
session_status=session_obj.status,
|
||||||
|
external_id=posted["external_id"],
|
||||||
|
posted_at=posted["posted_at"],
|
||||||
|
verified_status_id=verified_status_id,
|
||||||
|
verified_status_name=verified_status_name,
|
||||||
|
status_transition_skipped_reason=skipped_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helper used by tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _clear_preview_cache_for_tests() -> None:
|
||||||
|
"""Reset the singleton cache between tests."""
|
||||||
|
preview_cache._store.clear() # noqa: SLF001 — test-only access
|
||||||
@@ -25,6 +25,7 @@ from app.api.endpoints import (
|
|||||||
categories,
|
categories,
|
||||||
copilot,
|
copilot,
|
||||||
device_types,
|
device_types,
|
||||||
|
draft_templates,
|
||||||
feedback,
|
feedback,
|
||||||
flow_proposals,
|
flow_proposals,
|
||||||
flowpilot_analytics,
|
flowpilot_analytics,
|
||||||
@@ -41,8 +42,10 @@ from app.api.endpoints import (
|
|||||||
scripts,
|
scripts,
|
||||||
script_builder,
|
script_builder,
|
||||||
session_branches,
|
session_branches,
|
||||||
|
session_facts,
|
||||||
session_handoffs,
|
session_handoffs,
|
||||||
session_resolutions,
|
session_resolutions,
|
||||||
|
session_suggested_fixes,
|
||||||
sessions,
|
sessions,
|
||||||
shared,
|
shared,
|
||||||
shares,
|
shares,
|
||||||
@@ -135,6 +138,11 @@ api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
|
|||||||
# session_handoffs queue router must come before ai_sessions to avoid conflict
|
# session_handoffs queue router must come before ai_sessions to avoid conflict
|
||||||
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
|
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||||
|
# session_facts mounts under /ai-sessions/{id}/facts — register before ai_sessions
|
||||||
|
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
|
||||||
|
api_router.include_router(session_facts.router, dependencies=_tenant_deps)
|
||||||
|
api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps)
|
||||||
|
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ CRITICAL BEHAVIORS:
|
|||||||
- Act as a senior engineer, not a chatbot. Use your domain knowledge to SUGGEST diagnostic steps, not just record what the user says.
|
- Act as a senior engineer, not a chatbot. Use your domain knowledge to SUGGEST diagnostic steps, not just record what the user says.
|
||||||
- When the user describes a problem area, demonstrate understanding by naming specific sub-categories, common causes, and relevant tools.
|
- When the user describes a problem area, demonstrate understanding by naming specific sub-categories, common causes, and relevant tools.
|
||||||
- Challenge assumptions constructively: "Before we go down that path, have you considered checking X first? In my experience, that resolves 60% of these cases."
|
- Challenge assumptions constructively: "Before we go down that path, have you considered checking X first? In my experience, that resolves 60% of these cases."
|
||||||
- Capture SPECIFIC commands with exact syntax. Not "check the service" but "Get-Service ADSync | Select-Object Status, StartType".
|
- Capture SPECIFIC commands with exact syntax (PowerShell/CLI invocations the engineer would actually paste into a shell), not vague directives like "check the service".
|
||||||
- Include expected outcomes for every action: what does success look like?
|
- Include expected outcomes for every action: what does success look like?
|
||||||
- Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?"
|
- Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?"
|
||||||
- Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure."
|
- Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure."
|
||||||
@@ -74,7 +74,7 @@ STRUCTURAL RULES:
|
|||||||
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
|
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
|
||||||
|
|
||||||
CROSS-REFERENCE / LOOP-BACK PATTERN:
|
CROSS-REFERENCE / LOOP-BACK PATTERN:
|
||||||
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
|
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID — including ancestor decision nodes for re-verification loops. The target ID must already exist somewhere in the tree.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
INTERVIEW_PROTOCOL = """
|
INTERVIEW_PROTOCOL = """
|
||||||
@@ -85,7 +85,7 @@ Ask broad questions to understand the problem domain and scope:
|
|||||||
- What type of issue is this flow for?
|
- What type of issue is this flow for?
|
||||||
- Who is the target audience? (Tier 1 help desk, Tier 2, Tier 3?)
|
- Who is the target audience? (Tier 1 help desk, Tier 2, Tier 3?)
|
||||||
- What environment assumptions? (On-prem, hybrid, specific vendors?)
|
- What environment assumptions? (On-prem, hybrid, specific vendors?)
|
||||||
Demonstrate domain expertise immediately. If the user says "Azure AD Sync failures," show understanding: "Are you primarily seeing password hash sync issues, object attribute sync failures, or full directory sync errors?"
|
Demonstrate domain expertise immediately. When the user names a technology, ask a follow-up that proves you know its common failure modes — a sub-categorization question that only someone fluent in that area would think to ask. Use vocabulary native to whatever the user actually mentioned, not stock examples from past conversations.
|
||||||
DO NOT emit [TREE_UPDATE] during scoping. You are still understanding the problem.
|
DO NOT emit [TREE_UPDATE] during scoping. You are still understanding the problem.
|
||||||
|
|
||||||
PHASE 2 - DISCOVERY (current_phase: discovery):
|
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||||
@@ -130,7 +130,7 @@ Your response is natural conversational text. When the tree structure changes, i
|
|||||||
|
|
||||||
3. Metadata capture (when you learn the flow's name, description, or tags):
|
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||||
[METADATA]
|
[METADATA]
|
||||||
{"name": "...", "description": "...", "tags": ["..."]}
|
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||||
[/METADATA]
|
[/METADATA]
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
@@ -172,8 +172,8 @@ STRUCTURAL RULES:
|
|||||||
- All IDs must be unique descriptive slugs (e.g., "check-dns-resolution", not UUIDs)
|
- All IDs must be unique descriptive slugs (e.g., "check-dns-resolution", not UUIDs)
|
||||||
- The last step MUST be type "procedure_end"
|
- The last step MUST be type "procedure_end"
|
||||||
- Use section_headers to organize steps into logical phases
|
- Use section_headers to organize steps into logical phases
|
||||||
- Commands are arrays of objects: [{"code": "Get-Service ADSync", "label": "Check sync service", "language": "powershell"}]
|
- Commands are arrays of objects: [{"code": "<exact command>", "label": "<short label>", "language": "powershell|bash|cmd"}]
|
||||||
- Descriptions support [VAR:variable_name] interpolation for intake form variables (e.g., "Connect to [VAR:server_name] via RDP")
|
- Descriptions support [VAR:variable_name] interpolation for intake form variables. Pick variable names that fit the procedure being built — do not reuse names from prior conversations.
|
||||||
|
|
||||||
VARIABLE INTERPOLATION:
|
VARIABLE INTERPOLATION:
|
||||||
When the procedure needs per-execution input (server name, IP address, client name, etc.), use [VAR:variable_name] syntax in descriptions and commands. These map to intake form fields that the engineer fills in before starting.
|
When the procedure needs per-execution input (server name, IP address, client name, etc.), use [VAR:variable_name] syntax in descriptions and commands. These map to intake form fields that the engineer fills in before starting.
|
||||||
@@ -188,7 +188,7 @@ Understand the process being documented:
|
|||||||
- Who will execute it? (Tier 1 help desk, Tier 2, senior engineers?)
|
- Who will execute it? (Tier 1 help desk, Tier 2, senior engineers?)
|
||||||
- What environment context? (Specific vendor, on-prem vs cloud, tools available?)
|
- What environment context? (Specific vendor, on-prem vs cloud, tools available?)
|
||||||
- Will this need per-execution input? (server name, client info, IP addresses → intake form fields)
|
- Will this need per-execution input? (server name, client info, IP addresses → intake form fields)
|
||||||
Demonstrate domain expertise: if the user says "Exchange Online mailbox migration," show understanding: "Are we covering full tenant-to-tenant migration, on-prem to Exchange Online cutover, or individual mailbox moves with hybrid?"
|
Demonstrate domain expertise: when the user names a process, ask a sub-categorization question that distinguishes which variant of that process they mean (the variants will differ by technology — use vocabulary specific to whatever the user mentioned, not examples from prior chats).
|
||||||
DO NOT emit [STEPS_UPDATE] during scoping. You are still understanding the process.
|
DO NOT emit [STEPS_UPDATE] during scoping. You are still understanding the process.
|
||||||
|
|
||||||
PHASE 2 - DISCOVERY (current_phase: discovery):
|
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||||
@@ -238,12 +238,12 @@ Your response is natural conversational text. When the step structure changes, i
|
|||||||
|
|
||||||
3. Metadata capture (when you learn the flow's name, description, or tags):
|
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||||
[METADATA]
|
[METADATA]
|
||||||
{"name": "...", "description": "...", "tags": ["..."]}
|
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||||
[/METADATA]
|
[/METADATA]
|
||||||
|
|
||||||
4. Intake form suggestion (when intake form fields are identified):
|
4. Intake form suggestion (when intake form fields are identified):
|
||||||
[INTAKE_FORM]
|
[INTAKE_FORM]
|
||||||
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
[{"variable_name": "<snake_case_name>", "label": "<Human Label>", "field_type": "text|password|select|textarea|number|boolean", "required": true|false, "placeholder": "<short hint, optional>", "group_name": "<section heading, optional>", "display_order": <integer>}]
|
||||||
[/INTAKE_FORM]
|
[/INTAKE_FORM]
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
@@ -659,12 +659,12 @@ Requirements:
|
|||||||
|
|
||||||
Also provide metadata as a separate JSON object after the steps:
|
Also provide metadata as a separate JSON object after the steps:
|
||||||
[METADATA]
|
[METADATA]
|
||||||
{"name": "...", "description": "...", "tags": ["..."]}
|
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||||
[/METADATA]
|
[/METADATA]
|
||||||
|
|
||||||
If we discussed intake form fields, also include:
|
If we discussed intake form fields, also include:
|
||||||
[INTAKE_FORM]
|
[INTAKE_FORM]
|
||||||
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
[{"variable_name": "<snake_case_name>", "label": "<Human Label>", "field_type": "text|password|select|textarea|number|boolean", "required": true|false, "placeholder": "<short hint, optional>", "group_name": "<section heading, optional>", "display_order": <integer>}]
|
||||||
[/INTAKE_FORM]"""
|
[/INTAKE_FORM]"""
|
||||||
else:
|
else:
|
||||||
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
||||||
@@ -681,7 +681,7 @@ Requirements:
|
|||||||
|
|
||||||
Also provide metadata as a separate JSON object after the tree:
|
Also provide metadata as a separate JSON object after the tree:
|
||||||
[METADATA]
|
[METADATA]
|
||||||
{"name": "...", "description": "...", "tags": ["..."]}
|
{"name": "<flow name>", "description": "<one-sentence summary>", "tags": ["<tag1>", "<tag2>"]}
|
||||||
[/METADATA]"""
|
[/METADATA]"""
|
||||||
|
|
||||||
provider_messages.append({"role": "user", "content": generation_instruction})
|
provider_messages.append({"role": "user", "content": generation_instruction})
|
||||||
|
|||||||
@@ -89,8 +89,10 @@ Additional rules:
|
|||||||
5. Use unique node IDs prefixed with the branch context (e.g., "gpo-check-link")
|
5. Use unique node IDs prefixed with the branch context (e.g., "gpo-check-link")
|
||||||
6. Build the tree bottom-up in your head: create solution/leaf nodes first, then build parent nodes referencing their IDs
|
6. Build the tree bottom-up in your head: create solution/leaf nodes first, then build parent nodes referencing their IDs
|
||||||
|
|
||||||
Few-shot example showing correct action node next_node_id usage:
|
SHAPE-ONLY schema example (do not copy this content verbatim — it shows
|
||||||
{"id": "dns-root", "type": "decision", "question": "Can the client resolve any DNS names?", "help_text": "Run: nslookup google.com", "options": [{"id": "dns-opt-none", "label": "No — nslookup times out or returns 'server failed'", "next_node_id": "dns-check-service"}, {"id": "dns-opt-partial", "label": "Some names resolve but others fail", "next_node_id": "dns-check-specific"}], "children": [{"id": "dns-check-service", "type": "action", "title": "Check DNS Client Service", "description": "Verify the DNS Client service is running on the affected machine", "commands": ["Get-Service -Name Dnscache | Select-Object Status,StartType"], "expected_outcome": "Status should be Running", "next_node_id": "dns-service-solution"}, {"id": "dns-service-solution", "type": "solution", "title": "DNS Service Was Stopped", "description": "The DNS Client service was stopped, preventing all name resolution", "resolution_steps": ["Run: Start-Service Dnscache", "Set startup type: Set-Service Dnscache -StartupType Automatic", "Flush cache: ipconfig /flushdns", "Test: nslookup google.com"]}, {"id": "dns-check-specific", "type": "solution", "title": "Selective DNS Failure — Stale or Missing Records", "description": "Some records resolve correctly, indicating DNS is functional but specific records are stale or missing", "resolution_steps": ["Check DNS server for missing A/CNAME records", "Clear DNS cache on the DNS server: Clear-DnsServerCache", "Flush client cache: ipconfig /flushdns", "Verify with: nslookup <failing-hostname>"]}]}"""
|
how IDs link, NOT what to ask or run; your real tree must reflect the
|
||||||
|
branch the user described):
|
||||||
|
{"id": "<root-slug>", "type": "decision", "question": "<diagnostic question for THIS branch>", "help_text": "<optional hint>", "options": [{"id": "<opt-1>", "label": "<observable answer 1>", "next_node_id": "<child-1>"}, {"id": "<opt-2>", "label": "<observable answer 2>", "next_node_id": "<child-2>"}], "children": [{"id": "<child-1>", "type": "action", "title": "<what to do>", "description": "<details>", "commands": ["<exact command for THIS branch>"], "expected_outcome": "<what success looks like>", "next_node_id": "<sibling-id>"}, {"id": "<sibling-id>", "type": "solution", "title": "<resolution title>", "description": "<resolution description>", "resolution_steps": ["<step 1>", "<step 2>"]}, {"id": "<child-2>", "type": "solution", "title": "<other resolution>", "description": "<...>", "resolution_steps": ["<step 1>"]}]}"""
|
||||||
|
|
||||||
|
|
||||||
CORRECTIVE_PROMPT_TEMPLATE = """Your previous JSON was invalid for ResolutionFlow's tree schema.
|
CORRECTIVE_PROMPT_TEMPLATE = """Your previous JSON was invalid for ResolutionFlow's tree schema.
|
||||||
|
|||||||
@@ -129,6 +129,23 @@ class Settings(BaseSettings):
|
|||||||
"kb_convert": "standard",
|
"kb_convert": "standard",
|
||||||
"script_build": "standard",
|
"script_build": "standard",
|
||||||
"network_diagram_generate": "standard",
|
"network_diagram_generate": "standard",
|
||||||
|
# FlowPilot migration Phase 2 — short, latency-sensitive transformation
|
||||||
|
# of an engineer's answer/check output into a candidate fact.
|
||||||
|
# Doc Section 6.6 sets Haiku as the default; instrumentation tracks
|
||||||
|
# disputed_fact_rate so we can escalate to Sonnet if quality drops.
|
||||||
|
"fact_synthesis": "fast",
|
||||||
|
# FlowPilot migration Phase 3 — resolution-note preview that ships to
|
||||||
|
# the customer ticket. Sonnet because customer-facing artifact quality
|
||||||
|
# matters more than latency; the in-process state_version cache keeps
|
||||||
|
# cost manageable.
|
||||||
|
"resolution_note": "standard",
|
||||||
|
# FlowPilot migration Phase 4 — escalation handoff package. Parallel
|
||||||
|
# to resolution_note: Sonnet, same cache story, no MCP.
|
||||||
|
"escalation_package": "standard",
|
||||||
|
# FlowPilot migration Phase 5 — extract a parameter schema from a
|
||||||
|
# concrete rendered script so a draft_template can be proposed.
|
||||||
|
# Creates a persistent library artifact on accept, so Sonnet.
|
||||||
|
"template_extraction": "standard",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_model_for_action(self, action_type: str) -> str:
|
def get_model_for_action(self, action_type: str) -> str:
|
||||||
|
|||||||
@@ -153,48 +153,29 @@ Identify values that would change between executions (server names, IPs, usernam
|
|||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
Return a JSON object:
|
Return a JSON object with this SHAPE (DO NOT copy the placeholders below
|
||||||
|
verbatim — fill each field with content derived from the actual KB article
|
||||||
|
the engineer attached, NOT from this schema):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "Procedure title derived from the article",
|
"title": "<procedure title derived from the article>",
|
||||||
"description": "Brief description of what this procedure accomplishes",
|
"description": "<brief description of what this procedure accomplishes>",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"id": "unique-step-id",
|
"id": "<unique-kebab-case-id>",
|
||||||
"type": "step",
|
"type": "step|warning|section_header",
|
||||||
"content": "Open Server Manager and navigate to Add Roles on [VAR:server_name]",
|
"content": "<step body — may include [VAR:<your_variable>] interpolation>",
|
||||||
"confidence": 0.95,
|
"confidence": <float 0.0-1.0>,
|
||||||
"source_excerpt": "Step 1: Open Server Manager on DC01..."
|
"source_excerpt": "<the verbatim sentence/phrase from the article that this step came from>"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "warning-dns",
|
|
||||||
"type": "warning",
|
|
||||||
"content": "WARNING: This will restart DNS and cause brief connectivity loss",
|
|
||||||
"confidence": 0.90,
|
|
||||||
"source_excerpt": "Note: Restarting DNS will cause a brief outage"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "section-verification",
|
|
||||||
"type": "section_header",
|
|
||||||
"content": "Verification Steps",
|
|
||||||
"confidence": 1.0,
|
|
||||||
"source_excerpt": "Verification"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"intake_form": [
|
"intake_form": [
|
||||||
{
|
{
|
||||||
"variable_name": "server_name",
|
"variable_name": "<snake_case_name fitting THIS procedure>",
|
||||||
"label": "Server Name",
|
"label": "<Human Label>",
|
||||||
"field_type": "text",
|
"field_type": "text|password|select|textarea|number|boolean",
|
||||||
"required": true,
|
"required": true|false,
|
||||||
"display_order": 1
|
"display_order": <integer>
|
||||||
},
|
|
||||||
{
|
|
||||||
"variable_name": "ip_address",
|
|
||||||
"label": "IP Address",
|
|
||||||
"field_type": "text",
|
|
||||||
"required": true,
|
|
||||||
"display_order": 2
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, TYPE_CHECKING
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ class NetworkDiagram(Base):
|
|||||||
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
||||||
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
is_archived: Mapped[bool] = mapped_column(
|
is_archived: Mapped[bool] = mapped_column(
|
||||||
Boolean, nullable=False, default=False,
|
Boolean, nullable=False, default=False,
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ class ScriptBuilderSession(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
comment="Link to FlowPilot session if launched from there",
|
comment="Link to FlowPilot session if launched from there",
|
||||||
)
|
)
|
||||||
|
origin: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="standalone",
|
||||||
|
comment=(
|
||||||
|
"Session origin — 'standalone' (from /script-builder) or "
|
||||||
|
"'pilot_inline' (from FlowPilot Script Builder tab). "
|
||||||
|
"Invariant: pilot_inline rows must have ai_session_id set."
|
||||||
|
),
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class SessionSuggestedFix(Base):
|
|||||||
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
||||||
name="ck_session_suggested_fixes_user_decision",
|
name="ck_session_suggested_fixes_user_decision",
|
||||||
),
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||||
|
"'applied_partial', 'dismissed')",
|
||||||
|
name="ck_session_suggested_fixes_status",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
@@ -65,6 +70,21 @@ class SessionSuggestedFix(Base):
|
|||||||
JSONB, nullable=True
|
JSONB, nullable=True
|
||||||
)
|
)
|
||||||
user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
# Outcome dimension — did the fix work? Orthogonal to user_decision.
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="proposed"
|
||||||
|
)
|
||||||
|
applied_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
verified_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
|
JSONB, nullable=True
|
||||||
|
)
|
||||||
# Set when a newer suggested fix supersedes this one.
|
# Set when a newer suggested fix supersedes this one.
|
||||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
|
|||||||
68
backend/app/schemas/draft_template.py
Normal file
68
backend/app/schemas/draft_template.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Pydantic schemas for FlowPilot Phase 6 draft templates.
|
||||||
|
|
||||||
|
A draft is the engineer's "Run now, templatize after resolve" path output:
|
||||||
|
the script ran for the ticket, and the AI proposed a parameterization.
|
||||||
|
Post-resolve, the engineer accepts (promotes to a real template) or rejects.
|
||||||
|
|
||||||
|
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
DraftStatus = Literal["pending", "accepted", "rejected"]
|
||||||
|
|
||||||
|
|
||||||
|
class DraftTemplateResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
account_id: UUID
|
||||||
|
source_session_id: UUID
|
||||||
|
source_user_id: UUID
|
||||||
|
script_body: str
|
||||||
|
proposed_parameters: dict[str, Any]
|
||||||
|
proposed_name: str | None
|
||||||
|
proposed_category_id: UUID | None
|
||||||
|
status: DraftStatus
|
||||||
|
resolved_at: datetime | None
|
||||||
|
promoted_template_id: UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DraftTemplateListResponse(BaseModel):
|
||||||
|
drafts: list[DraftTemplateResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class DraftTemplateAcceptRequest(BaseModel):
|
||||||
|
"""Engineer's confirmation that this draft should become a real template.
|
||||||
|
|
||||||
|
Engineer may override the AI's proposed name / category and edit the
|
||||||
|
parameter schema before promotion. Body and parameters_schema are
|
||||||
|
persisted to the new `script_templates` row.
|
||||||
|
"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
category_id: UUID
|
||||||
|
description: str | None = Field(None, max_length=2000)
|
||||||
|
# Final parameter schema in the Script Generator's standard shape.
|
||||||
|
# See ScriptTemplate.parameters_schema for the contract.
|
||||||
|
parameters_schema: dict[str, Any]
|
||||||
|
# Optional last-minute edits to the script body. Defaults to the draft's
|
||||||
|
# `script_body` (which TemplateExtractionService produced as the templated
|
||||||
|
# form with `{{ key }}` placeholders).
|
||||||
|
edited_body: str | None = Field(None, min_length=1, max_length=50_000)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftTemplateAcceptResponse(BaseModel):
|
||||||
|
draft_id: UUID
|
||||||
|
promoted_template_id: UUID
|
||||||
|
template_slug: str
|
||||||
|
|
||||||
|
|
||||||
|
class DraftTemplateRejectResponse(BaseModel):
|
||||||
|
draft_id: UUID
|
||||||
|
status: Literal["rejected"]
|
||||||
@@ -53,9 +53,13 @@ class PSATicketSearchResult(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
summary: str
|
summary: str
|
||||||
company_name: str | None = None
|
company_name: str | None = None
|
||||||
|
company_id: str | None = None
|
||||||
board_name: str | None = None
|
board_name: str | None = None
|
||||||
|
board_id: int | None = None
|
||||||
status_name: str | None = None
|
status_name: str | None = None
|
||||||
|
status_id: int | None = None
|
||||||
priority_name: str | None = None
|
priority_name: str | None = None
|
||||||
|
priority_id: int | None = None
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
backend/app/schemas/psa_tickets.py
Normal file
65
backend/app/schemas/psa_tickets.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Normalized DTOs for ticket management endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PSAResourceSchema(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
member_name: str
|
||||||
|
member_identifier: str
|
||||||
|
is_rf_user: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PSATicketCreatedSchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
summary: str
|
||||||
|
board_name: str
|
||||||
|
status_name: str
|
||||||
|
priority_name: str
|
||||||
|
company_name: str
|
||||||
|
resources: list[PSAResourceSchema] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PSATicketStatusUpdateSchema(BaseModel):
|
||||||
|
ticket_id: int
|
||||||
|
previous_status: str
|
||||||
|
new_status: str
|
||||||
|
new_status_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreatePayloadSchema(BaseModel):
|
||||||
|
summary: str
|
||||||
|
company_id: int
|
||||||
|
board_id: int
|
||||||
|
status_id: int
|
||||||
|
priority_id: int
|
||||||
|
description: str | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketListResponseSchema(BaseModel):
|
||||||
|
items: list = []
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 25
|
||||||
|
|
||||||
|
|
||||||
|
class AiParseRequestSchema(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
class AiParseResponseSchema(BaseModel):
|
||||||
|
summary: str | None = None
|
||||||
|
company_id: int | None = None
|
||||||
|
board_id: int | None = None
|
||||||
|
priority_id: int | None = None
|
||||||
|
status_id: int | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PSAPrioritySchema(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
"""Pydantic schemas for the AI Script Builder."""
|
"""Pydantic schemas for the AI Script Builder."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Literal, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class ScriptBuilderCreateRequest(BaseModel):
|
class ScriptBuilderCreateRequest(BaseModel):
|
||||||
"""Request to start a new builder session."""
|
"""Request to start (or get-or-create, for inline origin) a builder session.
|
||||||
|
|
||||||
|
When `origin='pilot_inline'`, `ai_session_id` is REQUIRED and must
|
||||||
|
reference a pilot session owned by the current user. The endpoint's
|
||||||
|
get-or-create semantics kick in: if a pilot_inline session already
|
||||||
|
exists for (user_id, ai_session_id), that row is returned instead of
|
||||||
|
creating a duplicate.
|
||||||
|
"""
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="powershell",
|
default="powershell",
|
||||||
pattern=r"^(powershell|bash|python)$",
|
pattern=r"^(powershell|bash|python)$",
|
||||||
description="Script language",
|
description="Script language",
|
||||||
)
|
)
|
||||||
|
origin: Literal["standalone", "pilot_inline"] = "standalone"
|
||||||
|
ai_session_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScriptBuilderMessageRequest(BaseModel):
|
class ScriptBuilderMessageRequest(BaseModel):
|
||||||
|
|||||||
81
backend/app/schemas/session_fact.py
Normal file
81
backend/app/schemas/session_fact.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Pydantic schemas for the FlowPilot "What we know" session facts.
|
||||||
|
|
||||||
|
See FLOWPILOT-MIGRATION.md Section 4.2 for the data model rationale.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# AI-emittable source types are a subset (`user_note` is engineer-only).
|
||||||
|
AIEmittableSourceType = Literal["question", "diagnostic_check", "ai_synthesis"]
|
||||||
|
SourceType = Literal["question", "diagnostic_check", "user_note", "ai_synthesis"]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionFactResponse(BaseModel):
|
||||||
|
"""A single fact card in the What-we-know panel."""
|
||||||
|
id: UUID
|
||||||
|
session_id: UUID
|
||||||
|
text: str
|
||||||
|
source_type: SourceType
|
||||||
|
source_ref: UUID | None
|
||||||
|
source_summary: str | None
|
||||||
|
created_by: UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
# `editable` is computed server-side so the client doesn't have to
|
||||||
|
# re-encode the editability rule. It mirrors the PATCH endpoint's
|
||||||
|
# 403 policy: only user_note and ai_synthesis facts are editable.
|
||||||
|
editable: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": False}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionFactListResponse(BaseModel):
|
||||||
|
facts: list[SessionFactResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionFactCreateRequest(BaseModel):
|
||||||
|
"""Engineer-created manual fact (the "+ Add a note" affordance).
|
||||||
|
|
||||||
|
The endpoint hard-codes source_type="user_note" — manual creation cannot
|
||||||
|
spoof a question/check origin. Source-type-bound creation goes through
|
||||||
|
`/promote` instead.
|
||||||
|
"""
|
||||||
|
text: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
summary: str | None = Field(None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionFactUpdateRequest(BaseModel):
|
||||||
|
"""Edit an existing fact's text or summary.
|
||||||
|
|
||||||
|
The endpoint returns 403 when the fact's source_type is `question` or
|
||||||
|
`diagnostic_check` — those facts must be edited at the source item.
|
||||||
|
"""
|
||||||
|
text: str | None = Field(None, min_length=1, max_length=2000)
|
||||||
|
summary: str | None = Field(None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionFactPromoteRequest(BaseModel):
|
||||||
|
"""Promote a question answer / check result into a fact.
|
||||||
|
|
||||||
|
Two modes:
|
||||||
|
- **Direct**: caller provides `proposed_text` (and optionally `proposed_summary`).
|
||||||
|
The fact is persisted as-is. Used by the AI [PROMOTE] marker path and by the
|
||||||
|
engineer's "edit then save" affordance.
|
||||||
|
- **Synthesize**: caller provides `raw_input` (the engineer's typed answer or
|
||||||
|
the check output) and the server drafts `text`/`summary` via the
|
||||||
|
FactSynthesisService. The draft is persisted immediately for now —
|
||||||
|
the supervisor-staging review is a future enhancement (out of scope per
|
||||||
|
Section 12).
|
||||||
|
|
||||||
|
Exactly one of `proposed_text` or `raw_input` must be set.
|
||||||
|
"""
|
||||||
|
source_type: AIEmittableSourceType
|
||||||
|
source_ref: UUID | None = None
|
||||||
|
proposed_text: str | None = Field(None, min_length=1, max_length=2000)
|
||||||
|
proposed_summary: str | None = Field(None, max_length=200)
|
||||||
|
raw_input: str | None = Field(None, min_length=1, max_length=10_000)
|
||||||
166
backend/app/schemas/session_suggested_fix.py
Normal file
166
backend/app/schemas/session_suggested_fix.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""Pydantic schemas for session suggested fixes (Phase 3).
|
||||||
|
|
||||||
|
See FLOWPILOT-MIGRATION.md Section 5.2.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"]
|
||||||
|
|
||||||
|
# "dismissed" here is the outcome dimension — orthogonal to UserDecision's
|
||||||
|
# "dismissed" (script-path choice), though the migration backfill aligns
|
||||||
|
# them for pre-existing rows.
|
||||||
|
FixStatus = Literal[
|
||||||
|
"proposed",
|
||||||
|
"applied_success",
|
||||||
|
"applied_failed",
|
||||||
|
"applied_partial",
|
||||||
|
"dismissed",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSuggestedFixResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
session_id: UUID
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
confidence_pct: int
|
||||||
|
script_template_id: UUID | None
|
||||||
|
ai_drafted_script: str | None
|
||||||
|
ai_drafted_parameters: dict[str, Any] | None
|
||||||
|
user_decision: UserDecision | None
|
||||||
|
superseded_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
status: FixStatus
|
||||||
|
applied_at: datetime | None
|
||||||
|
verified_at: datetime | None
|
||||||
|
partial_notes: str | None
|
||||||
|
failure_reason: str | None
|
||||||
|
ai_outcome_proposal: dict[str, Any] | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSuggestedFixDecisionRequest(BaseModel):
|
||||||
|
"""Engineer's path choice on a suggested fix.
|
||||||
|
|
||||||
|
Server-side side effects per Section 5.2:
|
||||||
|
- one_off: record decision, return the rendered (AI-drafted or
|
||||||
|
engineer-edited) script. No persistent library artifact created.
|
||||||
|
- draft_template: same as one_off, plus TemplateExtractionService
|
||||||
|
proposes a parameterization and a draft_templates row is created.
|
||||||
|
- build_template: return a redirect payload pointing at the Script
|
||||||
|
Builder page, pre-loaded with the drafted script body.
|
||||||
|
- dismissed: mark the fix superseded.
|
||||||
|
|
||||||
|
For one_off / draft_template, the engineer may have edited the drafted
|
||||||
|
script or its parameters in the dialog. The final versions are sent
|
||||||
|
back here so we persist what will actually run.
|
||||||
|
"""
|
||||||
|
decision: UserDecision
|
||||||
|
# Present for one_off / draft_template — the engineer's final version of
|
||||||
|
# the drafted script after any inline edits. Omit to use the fix's
|
||||||
|
# `ai_drafted_script` verbatim.
|
||||||
|
edited_script: str | None = Field(None, min_length=1, max_length=50_000)
|
||||||
|
# Parameter values used when rendering (informational, stored on the
|
||||||
|
# draft_template row so a reviewer can see what the first run used).
|
||||||
|
parameters_used: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSuggestedFixDecisionResponse(BaseModel):
|
||||||
|
"""Returned after recording a decision."""
|
||||||
|
id: UUID
|
||||||
|
user_decision: UserDecision
|
||||||
|
# Populated for one_off / draft_template — the script to display/run.
|
||||||
|
rendered_script: str | None = None
|
||||||
|
# Populated for draft_template — the ID of the draft_templates row so
|
||||||
|
# the post-resolve TemplatizePrompt can fetch it in Phase 6.
|
||||||
|
draft_template_id: UUID | None = None
|
||||||
|
# Populated for build_template — where to send the engineer next.
|
||||||
|
redirect_path: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Where to send the engineer next (e.g. /scripts/builder?... for build_template)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Subset of FixStatus that the engineer can set via the outcome endpoint —
|
||||||
|
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
|
||||||
|
FixOutcome = Literal[
|
||||||
|
"applied_success", "applied_failed", "applied_partial", "dismissed"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSuggestedFixOutcomeRequest(BaseModel):
|
||||||
|
"""Engineer-reported outcome of applying a suggested fix.
|
||||||
|
|
||||||
|
Writes to session_suggested_fixes.status and companion columns. This is
|
||||||
|
orthogonal to `user_decision` (which records which script-path the
|
||||||
|
engineer took); outcome captures whether the fix actually worked.
|
||||||
|
|
||||||
|
Allowed transitions:
|
||||||
|
- from `proposed` or `applied_partial`: any outcome is valid
|
||||||
|
(partial is parked, not terminal — the engineer may update notes,
|
||||||
|
abandon via dismiss, or advance to success/failed)
|
||||||
|
- from any terminal outcome (`applied_success`, `applied_failed`,
|
||||||
|
`dismissed`): server returns 409
|
||||||
|
"""
|
||||||
|
outcome: FixOutcome
|
||||||
|
# Required for applied_partial, optional for applied_failed, ignored otherwise.
|
||||||
|
notes: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSuggestedFixScriptRequest(BaseModel):
|
||||||
|
"""Engineer-submitted drafted script for a suggested fix.
|
||||||
|
|
||||||
|
Called when the inline Script Builder tab's Submit action fires. The
|
||||||
|
fix must be non-terminal (still proposed/applied_partial). Setting
|
||||||
|
the script does NOT stamp applied_at — a draft is not an application.
|
||||||
|
"""
|
||||||
|
ai_drafted_script: str = Field(..., min_length=1, max_length=50_000)
|
||||||
|
ai_drafted_parameters: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolution note preview ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ResolutionNotePreviewResponse(BaseModel):
|
||||||
|
markdown: str
|
||||||
|
target_ticket_ref: str | None
|
||||||
|
state_version: int
|
||||||
|
from_cache: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 4: Resolve + Escalate post ───────────────────────────────────────
|
||||||
|
|
||||||
|
class ResolutionNotePostRequest(BaseModel):
|
||||||
|
"""Engineer-edited resolution markdown. Server posts to PSA + marks resolved."""
|
||||||
|
markdown: str = Field(..., min_length=1, max_length=20_000)
|
||||||
|
# Optional override for resolution summary shown on the session listing;
|
||||||
|
# defaults to the first line of the markdown if omitted.
|
||||||
|
resolution_summary: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationPackagePostRequest(BaseModel):
|
||||||
|
markdown: str = Field(..., min_length=1, max_length=20_000)
|
||||||
|
# Free-text reason shown in session listings and escalation queue.
|
||||||
|
escalation_reason: str | None = Field(None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionPostResponse(BaseModel):
|
||||||
|
"""Response shape for both Resolve/Escalate POST endpoints."""
|
||||||
|
# "resolved" / "escalated" / "resolved_local" / "escalated_local"
|
||||||
|
# The _local variants indicate the session has no linked PSA ticket —
|
||||||
|
# markdown is stored, session state is updated, nothing was posted externally.
|
||||||
|
outcome: str
|
||||||
|
session_status: str
|
||||||
|
external_id: str | None = None
|
||||||
|
posted_at: datetime | None = None
|
||||||
|
# Populated when a status transition was attempted and verified. None
|
||||||
|
# when no target status is configured in account_settings.preferences.
|
||||||
|
verified_status_id: int | None = None
|
||||||
|
verified_status_name: str | None = None
|
||||||
|
status_transition_skipped_reason: str | None = None
|
||||||
@@ -62,29 +62,31 @@ Every response you write MUST follow this exact structure:
|
|||||||
1. **1-3 sentences of analysis** (what the symptoms tell you)
|
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)
|
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)
|
3. **[ACTIONS] marker** with 1-4 diagnostic commands to run (if applicable)
|
||||||
|
4. **[PROMOTE] marker(s)** when the engineer's most recent message confirmed a fact \
|
||||||
|
worth recording (optional; see "Promoting facts" below)
|
||||||
|
|
||||||
You MUST include at least one marker ([QUESTIONS] or [ACTIONS]) in every response. \
|
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.
|
A response with only prose and no markers is INVALID and will break the UI. \
|
||||||
|
[PROMOTE] is optional and IN ADDITION to the required markers, never a replacement.
|
||||||
|
|
||||||
### Complete example of a correct first response:
|
### Format-only schema (DO NOT reuse the literal text below)
|
||||||
|
|
||||||
User: "Outlook disconnects every 10-15 min, Teams drops too, only this one user, WiFi"
|
The structure to follow is shown below using PLACEHOLDERS. The placeholders \
|
||||||
|
are not real questions or commands — they describe the SHAPE of valid output. \
|
||||||
|
Your real response must contain analysis and markers tailored to the actual \
|
||||||
|
ticket the engineer just sent. Reusing any placeholder text (or text from a \
|
||||||
|
prior unrelated example you've seen) verbatim is a bug.
|
||||||
|
|
||||||
Your response:
|
Analysis prose: 1-3 sentences specific to the engineer's symptoms.
|
||||||
|
|
||||||
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]
|
[QUESTIONS]
|
||||||
[{"text": "Is this user on a laptop or desktop?", "context": "Laptops have power management and docking transitions that cause WiFi drops"},
|
[{"text": "<one short, specific question about THIS ticket>", "context": "<one-sentence justification, optional>"},
|
||||||
{"text": "Are they on corporate WiFi or working from home?", "context": "Corporate WiFi with multiple APs can cause roaming disconnects"}]
|
{"text": "<another specific question>", "context": "<...>"}]
|
||||||
[/QUESTIONS]
|
[/QUESTIONS]
|
||||||
|
|
||||||
[ACTIONS]
|
[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": "<short imperative label for THIS ticket>", "command": "<exact PowerShell or shell command, omit for GUI-only steps>", "description": "<one sentence explaining what the output reveals>"},
|
||||||
{"label": "Check NIC power management", "command": "Get-NetAdapterPowerManagement | Select Name, AllowComputerToTurnOffDevice", "description": "If True, Windows is likely killing the adapter during idle periods"},
|
{"label": "<...>", "command": "<...>", "description": "<...>"}]
|
||||||
{"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]
|
[/ACTIONS]
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
@@ -112,6 +114,128 @@ information is no longer needed to resolve the issue. Default to keeping them.
|
|||||||
**Both markers are stripped from display** — the engineer sees them as interactive UI cards, \
|
**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.
|
not raw JSON. Put analysis BEFORE markers. Markers go at the END of your response.
|
||||||
|
|
||||||
|
## Promoting facts to "What we know"
|
||||||
|
|
||||||
|
The engineer has a "What we know" panel that holds confirmed facts about this \
|
||||||
|
session. Each confirmed fact stays visible to the engineer for the rest of the \
|
||||||
|
session and feeds the resolution note posted to the customer ticket. Surface \
|
||||||
|
facts there using a `[PROMOTE]` marker.
|
||||||
|
|
||||||
|
**When to emit [PROMOTE]:**
|
||||||
|
- The engineer just answered a [QUESTIONS] item with a substantive answer that \
|
||||||
|
rules something in or out
|
||||||
|
- The engineer just shared diagnostic-check output that confirmed a finding
|
||||||
|
- You synthesized a new conclusion from two or more prior facts
|
||||||
|
|
||||||
|
**When NOT to emit [PROMOTE]:**
|
||||||
|
- The engineer's answer was "unknown", "I don't know", or a clarifying question \
|
||||||
|
back to you
|
||||||
|
- The diagnostic output was empty, errored, or inconclusive
|
||||||
|
- You're re-stating something already in What we know
|
||||||
|
- The "fact" is your own hypothesis, not something the engineer confirmed
|
||||||
|
|
||||||
|
**[PROMOTE] marker format:**
|
||||||
|
Each fact is its own block. You may emit multiple blocks per response.
|
||||||
|
|
||||||
|
[PROMOTE]
|
||||||
|
{"source_type": "question", "source_ref": "<task_lane_item_id>", "text": "<one short past-tense sentence stating what is now confirmed FROM THIS TICKET>", "summary": "<3-7 word provenance label specific to what the fact rules in/out>"}
|
||||||
|
[/PROMOTE]
|
||||||
|
|
||||||
|
- `source_type` is one of: `"question"` (fact derived from a question's answer), \
|
||||||
|
`"diagnostic_check"` (fact derived from a check's output), or `"ai_synthesis"` \
|
||||||
|
(you combined prior facts).
|
||||||
|
- `source_ref` is the `id` field of the originating task-lane item — the \
|
||||||
|
[QUESTIONS] and [ACTIONS] payloads you receive in conversation context include \
|
||||||
|
an `id` for each item. Copy that UUID verbatim. For `ai_synthesis`, OMIT \
|
||||||
|
`source_ref` (or set it to null).
|
||||||
|
- `text` is a short past-tense sentence stating what's now confirmed. Use ONLY \
|
||||||
|
information present in the engineer's CURRENT message — never invent specifics, \
|
||||||
|
never reuse phrasing from past tickets or example payloads.
|
||||||
|
- `summary` names the diagnostic value (what the fact rules in or out), 3-7 \
|
||||||
|
words, no period.
|
||||||
|
|
||||||
|
**Strict rule:** [PROMOTE] is for confirmed facts only. If you're not certain \
|
||||||
|
the engineer's message confirms the fact, do not emit a [PROMOTE]. Hallucinated \
|
||||||
|
facts get posted to customer tickets and will erode trust in the system.
|
||||||
|
|
||||||
|
## Proposing a fix with [SUGGEST_FIX]
|
||||||
|
|
||||||
|
When you have a concrete proposed resolution path with reasonable confidence, \
|
||||||
|
emit a `[SUGGEST_FIX]` marker. This populates the "Suggested fix" card the \
|
||||||
|
engineer can act on (run a script, build a template, etc.). A new \
|
||||||
|
[SUGGEST_FIX] supersedes any prior suggested fix on the session — emit a fresh \
|
||||||
|
one whenever your top hypothesis changes meaningfully.
|
||||||
|
|
||||||
|
**When to emit [SUGGEST_FIX]:**
|
||||||
|
- You have a concrete resolution path (not just "investigate further")
|
||||||
|
- Confidence is at least ~50% — below that, keep diagnosing
|
||||||
|
- Either a known Script Library template applies, OR you can draft a script \
|
||||||
|
that resolves the issue end-to-end
|
||||||
|
|
||||||
|
**When NOT to emit [SUGGEST_FIX]:**
|
||||||
|
- You're still narrowing causes and the fix depends on the next answer
|
||||||
|
- The "fix" is just running another diagnostic — that goes in [ACTIONS]
|
||||||
|
- Two paths are equally likely — fork or ask first, suggest later
|
||||||
|
|
||||||
|
**[SUGGEST_FIX] marker format (one block per response, last one wins).**
|
||||||
|
Schema below — DO NOT copy these placeholders into your real response, fill \
|
||||||
|
each field with content specific to the actual ticket:
|
||||||
|
|
||||||
|
[SUGGEST_FIX]
|
||||||
|
{"title": "<short imperative summary of the fix, ≤200 chars>", "description": "<one short paragraph: root cause + how the fix resolves it>", "confidence": <integer 0-100>, "script_template_slug": "<slug-of-existing-template-or-omit>"}
|
||||||
|
[/SUGGEST_FIX]
|
||||||
|
|
||||||
|
- `title`: short imperative summary, ≤ 200 chars
|
||||||
|
- `description`: one short paragraph explaining the root cause and the fix
|
||||||
|
- `confidence`: integer 0-100 (what you'd bet this resolves the ticket)
|
||||||
|
- `script_template_slug`: slug of an existing Script Library template if one \
|
||||||
|
applies; OMIT or set null otherwise
|
||||||
|
- `ai_drafted_script`: full script body if no template matches (only when \
|
||||||
|
`script_template_slug` is null/omitted)
|
||||||
|
- `ai_drafted_parameters`: optional JSON object of suggested parameter values \
|
||||||
|
for the drafted script
|
||||||
|
|
||||||
|
The marker is stripped from display — the engineer sees the suggested fix as \
|
||||||
|
an interactive card with confidence badge, not raw JSON.
|
||||||
|
|
||||||
|
## Reporting fix outcome with [FIX_OUTCOME]
|
||||||
|
|
||||||
|
When the engineer clearly indicates in chat that a previously proposed fix
|
||||||
|
worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker
|
||||||
|
on its own lines. This surfaces a "confirm outcome?" banner in the UI — it
|
||||||
|
does NOT mark the fix resolved on its own; the engineer confirms via the UI.
|
||||||
|
|
||||||
|
**When to emit [FIX_OUTCOME]:**
|
||||||
|
- The engineer states the user's problem is resolved after applying the fix
|
||||||
|
(affirmative resolution language → outcome="success")
|
||||||
|
- The engineer states the issue persists after applying the fix
|
||||||
|
(→ outcome="failure")
|
||||||
|
- The engineer describes applying only part of the fix
|
||||||
|
(→ outcome="partial")
|
||||||
|
|
||||||
|
**When NOT to emit [FIX_OUTCOME]:**
|
||||||
|
- The engineer is still verifying (user rebooting, testing, etc.)
|
||||||
|
- The outcome is ambiguous or inferred rather than stated
|
||||||
|
- No [SUGGEST_FIX] has been emitted this session
|
||||||
|
|
||||||
|
**[FIX_OUTCOME] marker format (one block per response, on its own lines).**
|
||||||
|
Schema below — DO NOT copy these placeholders into your real response, fill \
|
||||||
|
each field with content specific to the actual ticket:
|
||||||
|
|
||||||
|
[FIX_OUTCOME]
|
||||||
|
{"fix_id": "<uuid-of-the-active-suggested-fix>",
|
||||||
|
"outcome": "<success|failure|partial>",
|
||||||
|
"reason": "<one-line-quote-or-paraphrase-of-what-the-engineer-said>"}
|
||||||
|
[/FIX_OUTCOME]
|
||||||
|
|
||||||
|
- `fix_id`: the UUID of the active suggested fix (provided in session context)
|
||||||
|
- `outcome`: one of `"success"`, `"failure"`, or `"partial"`
|
||||||
|
- `reason`: one-line paraphrase of what the engineer said — derived from \
|
||||||
|
their CURRENT message, not invented
|
||||||
|
|
||||||
|
The marker is stripped from display — the engineer sees a "confirm outcome?" \
|
||||||
|
banner in the UI, not raw JSON.
|
||||||
|
|
||||||
## Using the Team's Flow Library
|
## Using the Team's Flow Library
|
||||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||||
appear in the context below, reference them by name so the engineer can launch them \
|
appear in the context below, reference them by name so the engineer can launch them \
|
||||||
@@ -139,7 +263,7 @@ forking, branching, or paths to the engineer. You just continue the conversation
|
|||||||
The fork marker is metadata that the system uses behind the scenes.
|
The fork marker is metadata that the system uses behind the scenes.
|
||||||
|
|
||||||
**You MUST fork when:**
|
**You MUST fork when:**
|
||||||
- Symptoms affect multiple applications or layers (e.g., Outlook AND Teams dropping)
|
- Symptoms affect multiple applications or layers simultaneously
|
||||||
- The problem could be endpoint-side OR infrastructure-side
|
- The problem could be endpoint-side OR infrastructure-side
|
||||||
- Multiple well-known causes match the exact same symptom pattern
|
- Multiple well-known causes match the exact same symptom pattern
|
||||||
|
|
||||||
@@ -154,11 +278,6 @@ to those, not a replacement. Do NOT ask questions in prose — put them in [QUES
|
|||||||
|
|
||||||
Structure: 1-3 sentences of analysis → [QUESTIONS] and/or [ACTIONS] → [FORK] at the very end.
|
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 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 system creates branches silently. Based on the engineer's answer, you pick \
|
||||||
the most relevant branch to investigate first.
|
the most relevant branch to investigate first.
|
||||||
@@ -166,7 +285,7 @@ the most relevant branch to investigate first.
|
|||||||
To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
||||||
|
|
||||||
[FORK]
|
[FORK]
|
||||||
{"fork_reason": "Brief reason", "options": [{"label": "Short name", "description": "One sentence"}, {"label": "Another", "description": "One sentence"}]}
|
{"fork_reason": "<one short sentence: why these branches need independent investigation>", "options": [{"label": "<short hypothesis name for branch 1>", "description": "<one sentence: what this branch will check>"}, {"label": "<branch 2 name>", "description": "<...>"}]}
|
||||||
[/FORK]
|
[/FORK]
|
||||||
|
|
||||||
2-4 options. Never mention "fork", "branch", or "path" in your visible text.
|
2-4 options. Never mention "fork", "branch", or "path" in your visible text.
|
||||||
@@ -176,12 +295,48 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
|||||||
- If a question is clearly outside your domain, say so briefly and redirect.
|
- 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.
|
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
|
||||||
|
|
||||||
|
## SPIN-OFF TICKET CREATION
|
||||||
|
|
||||||
|
When you identify a second distinct issue that is clearly separate from the primary topic \
|
||||||
|
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
|
||||||
|
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
[ACTIONS]
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "Create ticket: <brief issue title>",
|
||||||
|
"command": "create_spin_off_ticket",
|
||||||
|
"description": "<one sentence description of the separate issue>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
[/ACTIONS]
|
||||||
|
|
||||||
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
|
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
|
||||||
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
|
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 \
|
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.
|
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
|
||||||
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
|
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
|
||||||
in your markers unless you are ≥75% confident that information is no longer relevant.
|
in your markers unless you are ≥75% confident that information is no longer relevant.
|
||||||
|
[PROMOTE] markers are OPTIONAL and IN ADDITION to the required ones — emit them only \
|
||||||
|
when the engineer's most recent message confirmed something worth recording, and copy \
|
||||||
|
the originating item's `id` into `source_ref` verbatim.
|
||||||
|
[SUGGEST_FIX] is OPTIONAL — emit one at most per response, only when you have a \
|
||||||
|
concrete proposed resolution at ~50%+ confidence. A new [SUGGEST_FIX] supersedes \
|
||||||
|
any prior suggested fix.
|
||||||
|
[FIX_OUTCOME] is OPTIONAL — emit one at most per response, only when the engineer \
|
||||||
|
has clearly stated the outcome in their current message.
|
||||||
|
|
||||||
|
ANTI-PARROT RULE: The schemas above use placeholders in `<angle brackets>` to show \
|
||||||
|
the SHAPE of valid output. Your real questions, actions, facts, and suggested fixes \
|
||||||
|
must be derived from the engineer's CURRENT message — never copy placeholder text, \
|
||||||
|
never reuse content from a prior unrelated session, never invent ticket-specific \
|
||||||
|
details (usernames, hostnames, IPs, error codes, application names, ticket numbers) \
|
||||||
|
that the engineer has not stated. The technology, vocabulary, and named entities in \
|
||||||
|
your output must match the technology, vocabulary, and named entities in the \
|
||||||
|
engineer's most recent message. If the engineer's ticket is about a different \
|
||||||
|
domain than the last ticket you saw, your output must reflect the new domain — \
|
||||||
|
do not let the previous ticket's specifics bleed into the new one.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
309
backend/app/services/escalation_package_generator.py
Normal file
309
backend/app/services/escalation_package_generator.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""EscalationPackageGeneratorService — drafts the handoff package for a session.
|
||||||
|
|
||||||
|
Parallel to ResolutionNoteGeneratorService but oriented around handoff to
|
||||||
|
another engineer instead of closing the ticket. The output markdown follows
|
||||||
|
FLOWPILOT-MIGRATION.md Section 6.3:
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
## What we've confirmed
|
||||||
|
## What we've tried
|
||||||
|
## Current hypothesis
|
||||||
|
## Suggested next steps
|
||||||
|
|
||||||
|
Same caching story as resolution-note previews: keyed on
|
||||||
|
`(session_id, ai_sessions.state_version)` via `preview_cache`, invalidated by
|
||||||
|
any fact / suggested-fix / script-generation write.
|
||||||
|
|
||||||
|
Model: Sonnet (`escalation_package` action tier per Section 6.6). MCP off.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.script_template import ScriptGeneration, ScriptTemplate
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
from app.services.preview_cache import preview_cache
|
||||||
|
from app.services.script_template_engine import ScriptTemplateEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_ESCALATION_SYSTEM_PROMPT = """\
|
||||||
|
You produce structured escalation handoff packages for an MSP troubleshooting \
|
||||||
|
platform. The package is read by the next engineer picking up the ticket; it \
|
||||||
|
must give them a running start without making them re-read the chat transcript.
|
||||||
|
|
||||||
|
Output exactly this markdown structure, no preamble, no closing remarks, no \
|
||||||
|
extra headings:
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
<one short paragraph stating the issue the first engineer was working on, \
|
||||||
|
past tense, no hedging. Derived from the session's intake/title and incident \
|
||||||
|
header.>
|
||||||
|
|
||||||
|
## What we've confirmed
|
||||||
|
<bulleted list of facts from the "What we know" section, each a short line. \
|
||||||
|
If there are no facts, write "Nothing confirmed yet." and continue.>
|
||||||
|
|
||||||
|
## What we've tried
|
||||||
|
<Bulleted list of diagnostic checks run and scripts generated during the \
|
||||||
|
session. The content of this section also depends on the outcome recorded for \
|
||||||
|
the active suggested fix, as given in the input bundle under "Outcome status":>
|
||||||
|
|
||||||
|
- applied_failed: List the fix as a tried path. Include the failure reason if \
|
||||||
|
provided. State that it did not resolve the issue.
|
||||||
|
- applied_partial: Include the fix as a partially tried path. Include partial \
|
||||||
|
notes if provided. Indicate it was not fully completed or not verified.
|
||||||
|
- applied_success: Note that the fix was applied and verified but escalation \
|
||||||
|
is still needed for another reason (unusual — reflect this accurately).
|
||||||
|
- dismissed: Do not mention the fix as a tried path; it was only considered.
|
||||||
|
- proposed (no outcome yet): Do not list it here; it goes in Current hypothesis.
|
||||||
|
|
||||||
|
If nothing has been tried at all (no checks, no scripts, no applied/partial \
|
||||||
|
fix), write "No diagnostic actions run yet." and continue.
|
||||||
|
|
||||||
|
## Current hypothesis
|
||||||
|
<The content depends on the outcome recorded for the active suggested fix:>
|
||||||
|
|
||||||
|
- proposed (no outcome yet): State the fix title and confidence. If confidence \
|
||||||
|
is below 60% or there is no active fix, say "No leading hypothesis yet — \
|
||||||
|
symptoms are still being narrowed."
|
||||||
|
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
|
||||||
|
aside. State any remaining uncertainty.
|
||||||
|
- applied_partial: Note the partial application and what remains open.
|
||||||
|
- applied_success: Unusual in an escalate path — state the fix resolved the \
|
||||||
|
original symptom but a new or related issue requires escalation.
|
||||||
|
|
||||||
|
## Suggested next steps
|
||||||
|
<bulleted list of 2-4 concrete next actions the receiving engineer should \
|
||||||
|
take. Prefer specifics: commands to run, tickets to check, people to contact. \
|
||||||
|
Derive from the gap between confirmed facts and a complete resolution. \
|
||||||
|
If the active suggested fix failed (applied_failed), inform the next steps \
|
||||||
|
accordingly — e.g. suggest alternatives or deeper investigation paths, \
|
||||||
|
drawing on the failure reason if provided. \
|
||||||
|
If the fix is partially applied (applied_partial), the first step is typically \
|
||||||
|
to complete or verify it. \
|
||||||
|
If the fix is still proposed (no outcome), the first step is to try it if \
|
||||||
|
confidence is high (>80%).>
|
||||||
|
|
||||||
|
Strict rules:
|
||||||
|
- Use ONLY the input I provide. Never invent command names, KB articles, or \
|
||||||
|
configuration specifics that aren't in the input.
|
||||||
|
- Do not include placeholder text like "TBD" or empty bullets.
|
||||||
|
- Do not include the engineer's name, the AI's name, session IDs, or the \
|
||||||
|
chat transcript verbatim.
|
||||||
|
- Markdown headings exactly as shown (## level), no bolding.
|
||||||
|
- The tone is a peer handing off to a peer, not a status report.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationPackageGeneratorService:
|
||||||
|
"""Generates and caches the five-section Escalate handoff markdown."""
|
||||||
|
|
||||||
|
KIND = "escalation_package"
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def generate_or_get_cached(
|
||||||
|
self, session_id: UUID, *, force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
session = await self._load_session(session_id)
|
||||||
|
cached = preview_cache.get(self.KIND, session.id, session.state_version) if not force else None
|
||||||
|
if cached is not None:
|
||||||
|
return {**cached, "from_cache": True}
|
||||||
|
|
||||||
|
markdown = await self._render(session)
|
||||||
|
target = self._target_ticket_ref(session)
|
||||||
|
payload = {
|
||||||
|
"markdown": markdown,
|
||||||
|
"target_ticket_ref": target,
|
||||||
|
"state_version": session.state_version,
|
||||||
|
}
|
||||||
|
preview_cache.set(self.KIND, session.id, session.state_version, payload)
|
||||||
|
return {**payload, "from_cache": False}
|
||||||
|
|
||||||
|
# ── Internals (parallel to ResolutionNoteGenerator) ───────────────────
|
||||||
|
|
||||||
|
async def _load_session(self, session_id: UUID) -> AISession:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AISession).where(AISession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise ValueError(f"Session {session_id} not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _render(self, session: AISession) -> str:
|
||||||
|
facts = await self._load_facts(session.id)
|
||||||
|
active_fix = await self._load_active_fix(session.id)
|
||||||
|
gens = await self._load_redacted_generations(session.id)
|
||||||
|
|
||||||
|
bundle = self._build_input_bundle(session, facts, active_fix, gens)
|
||||||
|
|
||||||
|
model = settings.get_model_for_action("escalation_package")
|
||||||
|
provider = get_ai_provider(model=model)
|
||||||
|
|
||||||
|
system_blocks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": _ESCALATION_SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
# cacheable: identical across every escalation-package preview call
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, _in, _out = await provider.generate_text(
|
||||||
|
system_prompt=system_blocks,
|
||||||
|
messages=[{"role": "user", "content": bundle}],
|
||||||
|
max_tokens=1400,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Escalation package generation failed for session %s", session.id)
|
||||||
|
raise
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
async def _load_facts(self, session_id: UUID) -> list[SessionFact]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionFact)
|
||||||
|
.where(
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionFact.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _load_active_fix(self, session_id: UUID) -> SessionSuggestedFix | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionSuggestedFix.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def _load_redacted_generations(
|
||||||
|
self, session_id: UUID
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ScriptGeneration)
|
||||||
|
.where(ScriptGeneration.ai_session_id == session_id)
|
||||||
|
.order_by(ScriptGeneration.created_at.asc())
|
||||||
|
)
|
||||||
|
gens = list(result.scalars().all())
|
||||||
|
if not gens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
template_ids = {g.template_id for g in gens}
|
||||||
|
tpl_result = await self.db.execute(
|
||||||
|
select(ScriptTemplate).where(ScriptTemplate.id.in_(template_ids))
|
||||||
|
)
|
||||||
|
templates_by_id = {t.id: t for t in tpl_result.scalars().all()}
|
||||||
|
|
||||||
|
engine = ScriptTemplateEngine()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for g in gens:
|
||||||
|
tpl = templates_by_id.get(g.template_id)
|
||||||
|
sensitive_keys: set[str] = set()
|
||||||
|
schema = (tpl.parameters_schema if tpl else {}) or {}
|
||||||
|
params = schema.get("parameters") if isinstance(schema, dict) else None
|
||||||
|
if isinstance(params, list):
|
||||||
|
for p in params:
|
||||||
|
if isinstance(p, dict) and p.get("field_type") == "password":
|
||||||
|
k = p.get("key") or p.get("variable_name")
|
||||||
|
if isinstance(k, str):
|
||||||
|
sensitive_keys.add(k)
|
||||||
|
redacted_params = engine.redact_sensitive(g.parameters_used or {}, sensitive_keys)
|
||||||
|
out.append({
|
||||||
|
"template_name": tpl.name if tpl else "(unknown template)",
|
||||||
|
"template_slug": tpl.slug if tpl else None,
|
||||||
|
"parameters_used": redacted_params,
|
||||||
|
"created_at": g.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target_ticket_ref(session: AISession) -> str | None:
|
||||||
|
if not session.psa_ticket_id:
|
||||||
|
return None
|
||||||
|
return f"CW #{session.psa_ticket_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_input_bundle(
|
||||||
|
session: AISession,
|
||||||
|
facts: list[SessionFact],
|
||||||
|
active_fix: SessionSuggestedFix | None,
|
||||||
|
generations: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("# Session context")
|
||||||
|
lines.append(f"Title: {session.title or '(untitled)'}")
|
||||||
|
if session.problem_summary:
|
||||||
|
lines.append(f"Problem summary: {session.problem_summary}")
|
||||||
|
if session.problem_domain:
|
||||||
|
lines.append(f"Domain: {session.problem_domain}")
|
||||||
|
intake_text = (session.intake_content or {}).get("text") if isinstance(session.intake_content, dict) else None
|
||||||
|
if intake_text:
|
||||||
|
lines.append(f"Intake message: {intake_text}")
|
||||||
|
if session.psa_ticket_id:
|
||||||
|
lines.append(f"Linked PSA ticket: CW #{session.psa_ticket_id}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Confirmed facts (What we know)")
|
||||||
|
if not facts:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for f in facts:
|
||||||
|
tag = f.source_type
|
||||||
|
summary = f" — {f.source_summary}" if f.source_summary else ""
|
||||||
|
lines.append(f"- [{tag}] {f.text}{summary}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Diagnostic checks run during the session")
|
||||||
|
check_facts = [f for f in facts if f.source_type == "diagnostic_check"]
|
||||||
|
if not check_facts and not generations:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for f in check_facts:
|
||||||
|
lines.append(f"- {f.text}")
|
||||||
|
for g in generations:
|
||||||
|
lines.append(f"- Ran script {g['template_name']} (slug={g['template_slug']})")
|
||||||
|
if g["parameters_used"]:
|
||||||
|
lines.append(f" parameters: {g['parameters_used']}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Active suggested fix (current hypothesis)")
|
||||||
|
if active_fix is None:
|
||||||
|
lines.append("(no active suggested fix)")
|
||||||
|
else:
|
||||||
|
lines.append(f"Title: {active_fix.title}")
|
||||||
|
lines.append(f"Confidence: {active_fix.confidence_pct}%")
|
||||||
|
lines.append(f"Description: {active_fix.description}")
|
||||||
|
lines.append(f"Outcome status: {active_fix.status}")
|
||||||
|
if active_fix.applied_at:
|
||||||
|
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
|
||||||
|
if active_fix.verified_at:
|
||||||
|
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||||
|
if active_fix.partial_notes:
|
||||||
|
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||||
|
if active_fix.failure_reason:
|
||||||
|
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Produce the five-section escalation handoff now. Use only the input above."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
285
backend/app/services/fact_synthesis_service.py
Normal file
285
backend/app/services/fact_synthesis_service.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"""FactSynthesisService — converts engineer answers and check output into facts.
|
||||||
|
|
||||||
|
Two paths feed this service:
|
||||||
|
|
||||||
|
1. **AI marker path (the common case).** When the model emits a `[PROMOTE]`
|
||||||
|
marker in the chat stream, `unified_chat_service` parses the marker (which
|
||||||
|
already contains the engineer-readable `text` and short provenance `summary`)
|
||||||
|
and calls `create_fact` directly. No LLM call is needed — the model already
|
||||||
|
wrote the fact.
|
||||||
|
|
||||||
|
2. **Engineer-driven synthesize path.** The "+ Promote to What we know" affordance
|
||||||
|
in the UI sends a raw answer or check output and asks the server to draft
|
||||||
|
`text` + `summary` for review. `synthesize_from_question` /
|
||||||
|
`synthesize_from_check` make a small Haiku call for that draft. The engineer
|
||||||
|
confirms (or edits) before persistence, so the LLM output is never
|
||||||
|
silently posted to a customer ticket.
|
||||||
|
|
||||||
|
Either way, persistence funnels through `create_fact`, which ALSO bumps
|
||||||
|
`ai_sessions.state_version` so the resolution-note preview cache invalidates
|
||||||
|
(see FLOWPILOT-MIGRATION.md Section 5.5).
|
||||||
|
|
||||||
|
Model tier is `fact_synthesis` in `settings.ACTION_MODEL_MAP` (Haiku per
|
||||||
|
Section 6.6). MCP is intentionally disabled for synthesis — these are
|
||||||
|
pure transformations of input, not research calls.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Conservative synthesis prompt. Hallucinated specifics are a trust-killer
|
||||||
|
# because facts feed the resolution note posted to customer tickets — the
|
||||||
|
# prompt makes "no fact" an explicit, valid output.
|
||||||
|
_SYNTHESIS_SYSTEM_PROMPT = """\
|
||||||
|
You convert one engineer answer or one diagnostic-check output into a single \
|
||||||
|
candidate fact for a troubleshooting session's "What we know" log.
|
||||||
|
|
||||||
|
Return strict JSON with this shape:
|
||||||
|
{
|
||||||
|
"text": "<one short sentence stating what is now known, in past tense>",
|
||||||
|
"summary": "<3-7 word provenance label, e.g. 'rules out tenant/license'>"
|
||||||
|
}
|
||||||
|
|
||||||
|
If the answer/output does NOT contain a substantive fact (e.g. the engineer \
|
||||||
|
typed 'unknown', the command failed, the output is empty), return:
|
||||||
|
{
|
||||||
|
"text": null,
|
||||||
|
"summary": null
|
||||||
|
}
|
||||||
|
|
||||||
|
Strict rules:
|
||||||
|
- Use ONLY information present in the input. Never add details that were not stated.
|
||||||
|
- Do not speculate, infer causes, or extrapolate. State only what the input proves.
|
||||||
|
- The text is a fact a colleague could verify by looking at the original answer/output.
|
||||||
|
- The summary names the diagnostic value (what this fact rules in or out), not the topic.
|
||||||
|
- Output ONLY the JSON object, no prose, no markdown fences.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FactSynthesisService:
|
||||||
|
"""Persists session facts and (optionally) drafts them via an LLM call.
|
||||||
|
|
||||||
|
Methods that touch the database take an `AsyncSession` and assume the
|
||||||
|
caller commits. `create_fact` flushes so the returned row has a primary key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
# ── Persistence ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_fact(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
source_type: str,
|
||||||
|
text: str,
|
||||||
|
summary: str | None = None,
|
||||||
|
source_ref: UUID | None = None,
|
||||||
|
) -> SessionFact:
|
||||||
|
"""Persist a fact and bump the session's preview-cache version.
|
||||||
|
|
||||||
|
`source_ref` MUST be None for `user_note` and `ai_synthesis` sources;
|
||||||
|
for `question` and `diagnostic_check` it should point at the stable
|
||||||
|
UUID of the originating task-lane item. The DB has no FK constraint
|
||||||
|
on `source_ref` (the target lives inside JSONB) — integrity is enforced
|
||||||
|
here.
|
||||||
|
"""
|
||||||
|
if source_type not in ("question", "diagnostic_check", "user_note", "ai_synthesis"):
|
||||||
|
raise ValueError(f"Invalid source_type: {source_type}")
|
||||||
|
|
||||||
|
if source_type in ("user_note", "ai_synthesis") and source_ref is not None:
|
||||||
|
# `source_ref` is a back-pointer to a question/check; user notes
|
||||||
|
# and AI-synthesized facts have no source item to point at.
|
||||||
|
raise ValueError(
|
||||||
|
f"source_ref must be None for source_type={source_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Fact text cannot be empty")
|
||||||
|
|
||||||
|
fact = SessionFact(
|
||||||
|
session_id=session_id,
|
||||||
|
account_id=account_id,
|
||||||
|
text=text,
|
||||||
|
source_type=source_type,
|
||||||
|
source_ref=source_ref,
|
||||||
|
source_summary=(summary or "").strip() or None,
|
||||||
|
created_by=user_id,
|
||||||
|
)
|
||||||
|
self.db.add(fact)
|
||||||
|
|
||||||
|
# Invalidate any preview cached against the prior state_version.
|
||||||
|
# Single UPDATE so the bump is atomic relative to the fact insert
|
||||||
|
# within this transaction; concurrent writers serialize on the row.
|
||||||
|
await self.db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
await self.db.flush()
|
||||||
|
return fact
|
||||||
|
|
||||||
|
async def soft_delete_fact(self, fact: SessionFact) -> None:
|
||||||
|
"""Mark a fact deleted and bump state_version."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
fact.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await self.db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == fact.session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
async def update_fact(
|
||||||
|
self,
|
||||||
|
fact: SessionFact,
|
||||||
|
*,
|
||||||
|
text: str | None = None,
|
||||||
|
summary: str | None = None,
|
||||||
|
) -> SessionFact:
|
||||||
|
"""Update an editable fact and bump state_version.
|
||||||
|
|
||||||
|
Caller is responsible for the editability check — only `user_note`
|
||||||
|
and `ai_synthesis` facts may be edited at the card level. The
|
||||||
|
endpoint enforces this and returns 403 for the read-only types.
|
||||||
|
"""
|
||||||
|
if text is not None:
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("Fact text cannot be empty")
|
||||||
|
fact.text = stripped
|
||||||
|
if summary is not None:
|
||||||
|
fact.source_summary = summary.strip() or None
|
||||||
|
|
||||||
|
await self.db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == fact.session_id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
await self.db.flush()
|
||||||
|
return fact
|
||||||
|
|
||||||
|
# ── LLM-backed drafting ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def synthesize_from_question(
|
||||||
|
self, *, question_text: str, raw_answer: str
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
"""Draft `{text, summary}` from a question + engineer's free-text answer.
|
||||||
|
|
||||||
|
Returns `{"text": None, "summary": None}` when the answer doesn't
|
||||||
|
contain a substantive fact — caller should not persist in that case.
|
||||||
|
"""
|
||||||
|
return await self._synthesize(
|
||||||
|
user_input=(
|
||||||
|
f"Question asked: {question_text.strip()}\n"
|
||||||
|
f"Engineer's answer: {raw_answer.strip()}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def synthesize_from_check(
|
||||||
|
self, *, check_label: str, check_output: str
|
||||||
|
) -> dict[str, str | None]:
|
||||||
|
"""Draft `{text, summary}` from a diagnostic check label + its output."""
|
||||||
|
return await self._synthesize(
|
||||||
|
user_input=(
|
||||||
|
f"Diagnostic check: {check_label.strip()}\n"
|
||||||
|
f"Output:\n{check_output.strip()}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _synthesize(self, *, user_input: str) -> dict[str, str | None]:
|
||||||
|
"""Single Haiku call with the conservative synthesis prompt."""
|
||||||
|
model = settings.get_model_for_action("fact_synthesis")
|
||||||
|
provider = get_ai_provider(model=model)
|
||||||
|
|
||||||
|
# Cache the system prompt — it's identical across every synthesis call.
|
||||||
|
system_blocks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": _SYNTHESIS_SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
# cacheable: identical across all fact-synthesis calls
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, _in, _out = await provider.generate_json(
|
||||||
|
system_prompt=system_blocks,
|
||||||
|
messages=[{"role": "user", "content": user_input}],
|
||||||
|
max_tokens=200,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Fact synthesis LLM call failed")
|
||||||
|
return {"text": None, "summary": None}
|
||||||
|
|
||||||
|
return self._parse_synthesis_response(text)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_synthesis_response(raw: str) -> dict[str, str | None]:
|
||||||
|
"""Tolerant parse: strip fences, accept null fields, ignore extras."""
|
||||||
|
cleaned = raw.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||||||
|
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
logger.warning("Fact synthesis returned non-JSON: %r", raw[:200])
|
||||||
|
return {"text": None, "summary": None}
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"text": None, "summary": None}
|
||||||
|
|
||||||
|
text = data.get("text")
|
||||||
|
summary = data.get("summary")
|
||||||
|
if text is not None and not isinstance(text, str):
|
||||||
|
text = None
|
||||||
|
if summary is not None and not isinstance(summary, str):
|
||||||
|
summary = None
|
||||||
|
|
||||||
|
# Treat empty strings the same as null — "no substantive fact".
|
||||||
|
if isinstance(text, str) and not text.strip():
|
||||||
|
text = None
|
||||||
|
if isinstance(summary, str) and not summary.strip():
|
||||||
|
summary = None
|
||||||
|
|
||||||
|
return {"text": text, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_facts_for_session(
|
||||||
|
db: AsyncSession, session_id: UUID
|
||||||
|
) -> list[SessionFact]:
|
||||||
|
"""List non-deleted facts for a session, oldest first.
|
||||||
|
|
||||||
|
RLS filters by tenant; the explicit account_id check is unnecessary here.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SessionFact)
|
||||||
|
.where(
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionFact.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
52
backend/app/services/preview_cache.py
Normal file
52
backend/app/services/preview_cache.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""In-process preview cache for FlowPilot resolution-note / escalation-package previews.
|
||||||
|
|
||||||
|
Phase 3 implementation per FLOWPILOT-MIGRATION.md Section 5.5:
|
||||||
|
- Cache key: `(kind, session_id, state_version)` — no TTL needed, state_version
|
||||||
|
is the source of truth.
|
||||||
|
- Invalidation: any write to session_facts, session_suggested_fixes, or
|
||||||
|
script_generations bumps `ai_sessions.state_version`. Old entries simply
|
||||||
|
stop being looked up and leak harmlessly until process restart.
|
||||||
|
- Storage: plain dict, single-process. When Session Sharing brings Redis,
|
||||||
|
swap the storage without changing the call sites.
|
||||||
|
|
||||||
|
Bound: best-effort soft cap of 5000 entries. When exceeded we drop the
|
||||||
|
oldest insertion. Not a TTL — at current scale, the cap is more about
|
||||||
|
resident-memory hygiene than correctness.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
_MAX_ENTRIES = 5000
|
||||||
|
|
||||||
|
|
||||||
|
class _PreviewCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: OrderedDict[tuple[str, UUID, int], Any] = OrderedDict()
|
||||||
|
|
||||||
|
def get(self, kind: str, session_id: UUID, state_version: int) -> Any | None:
|
||||||
|
key = (kind, session_id, state_version)
|
||||||
|
if key not in self._store:
|
||||||
|
return None
|
||||||
|
# Touch on access so LRU eviction is meaningful.
|
||||||
|
self._store.move_to_end(key)
|
||||||
|
return self._store[key]
|
||||||
|
|
||||||
|
def set(self, kind: str, session_id: UUID, state_version: int, value: Any) -> None:
|
||||||
|
key = (kind, session_id, state_version)
|
||||||
|
self._store[key] = value
|
||||||
|
self._store.move_to_end(key)
|
||||||
|
# Evict oldest if over cap. OrderedDict.popitem(last=False) is O(1).
|
||||||
|
while len(self._store) > _MAX_ENTRIES:
|
||||||
|
self._store.popitem(last=False)
|
||||||
|
|
||||||
|
def invalidate_session(self, session_id: UUID) -> None:
|
||||||
|
"""Drop all entries for a session — used when the session is deleted."""
|
||||||
|
keys = [k for k in self._store if k[1] == session_id]
|
||||||
|
for k in keys:
|
||||||
|
del self._store[k]
|
||||||
|
|
||||||
|
|
||||||
|
preview_cache = _PreviewCache()
|
||||||
@@ -12,6 +12,10 @@ from app.services.psa.types import (
|
|||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
PSABoard,
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ class AutotaskProvider(PSAProvider):
|
|||||||
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
async def post_note(
|
async def post_note(
|
||||||
@@ -74,3 +78,18 @@ class AutotaskProvider(PSAProvider):
|
|||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> PSATimeEntry:
|
) -> PSATimeEntry:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ from .types import (
|
|||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
PSABoard,
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ class PSAProvider(ABC):
|
|||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
...
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -83,3 +87,23 @@ class PSAProvider(ABC):
|
|||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> PSATimeEntry:
|
) -> PSATimeEntry:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from app.services.psa.base import PSAProvider
|
from app.services.psa.base import PSAProvider
|
||||||
from app.services.psa.cache import psa_cache
|
from app.services.psa.cache import psa_cache
|
||||||
|
from app.services.psa.exceptions import PSAError
|
||||||
from app.services.psa.types import (
|
from app.services.psa.types import (
|
||||||
ConnectionTestResult,
|
ConnectionTestResult,
|
||||||
PSATicket,
|
PSATicket,
|
||||||
@@ -17,6 +18,10 @@ from app.services.psa.types import (
|
|||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
PSABoard,
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
from .client import ConnectWiseClient
|
from .client import ConnectWiseClient
|
||||||
|
|
||||||
@@ -55,27 +60,31 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
)
|
)
|
||||||
return self._map_ticket(data)
|
return self._map_ticket(data)
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
|
"""Search CW tickets by summary. Supports board_id, status_id, member_identifier,
|
||||||
unassigned, board_ids, page, and page_size filters."""
|
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
|
||||||
page_size = filters.get("page_size", 10)
|
page_size = filters.get("page_size", 10)
|
||||||
page = filters.get("page", 1)
|
page = filters.get("page", 1)
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||||
"orderBy": "id desc",
|
"orderBy": "priority/sort asc,dateEntered desc",
|
||||||
"pageSize": page_size,
|
"pageSize": page_size,
|
||||||
"page": page,
|
"page": page,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build CW condition query
|
|
||||||
conditions: list[str] = []
|
conditions: list[str] = []
|
||||||
if query:
|
if query:
|
||||||
conditions.append(f"summary contains '{query}'")
|
# Sanitize: strip single quotes to prevent CW condition injection
|
||||||
|
safe_query = query.replace("'", "")
|
||||||
|
conditions.append(f"summary contains '{safe_query}'")
|
||||||
if filters.get("board_id"):
|
if filters.get("board_id"):
|
||||||
conditions.append(f"board/id = {filters['board_id']}")
|
conditions.append(f"board/id = {filters['board_id']}")
|
||||||
if filters.get("status_id"):
|
if filters.get("status_id"):
|
||||||
conditions.append(f"status/id = {filters['status_id']}")
|
conditions.append(f"status/id = {filters['status_id']}")
|
||||||
|
elif filters.get("status_name"):
|
||||||
|
safe_status = str(filters["status_name"]).replace("'", "")
|
||||||
|
conditions.append(f"status/name = '{safe_status}'")
|
||||||
if not filters.get("include_closed", False):
|
if not filters.get("include_closed", False):
|
||||||
conditions.append("closedFlag = false")
|
conditions.append("closedFlag = false")
|
||||||
if filters.get("member_identifier") is not None:
|
if filters.get("member_identifier") is not None:
|
||||||
@@ -86,16 +95,27 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
if board_ids:
|
if board_ids:
|
||||||
board_list = ", ".join(str(bid) for bid in board_ids)
|
board_list = ", ".join(str(bid) for bid in board_ids)
|
||||||
conditions.append(f"board/id in ({board_list})")
|
conditions.append(f"board/id in ({board_list})")
|
||||||
|
if filters.get("company_id"):
|
||||||
|
conditions.append(f"company/id = {int(filters['company_id'])}")
|
||||||
|
|
||||||
if conditions:
|
condition_str = " and ".join(conditions) if conditions else ""
|
||||||
params["conditions"] = " and ".join(conditions)
|
if condition_str:
|
||||||
|
params["conditions"] = condition_str
|
||||||
|
|
||||||
data = await self.client.get("/service/tickets", params=params)
|
count_params: dict = {}
|
||||||
|
if condition_str:
|
||||||
|
count_params["conditions"] = condition_str
|
||||||
|
|
||||||
return [
|
# Fire page fetch + count in parallel
|
||||||
self._map_ticket(t)
|
data, count_data = await asyncio.gather(
|
||||||
for t in (data if isinstance(data, list) else [])
|
self.client.get("/service/tickets", params=params),
|
||||||
]
|
self.client.get("/service/tickets/count", params=count_params),
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])]
|
||||||
|
total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items)
|
||||||
|
|
||||||
|
return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
async def get_ticket_configurations(
|
async def get_ticket_configurations(
|
||||||
self, ticket_id: str
|
self, ticket_id: str
|
||||||
@@ -246,13 +266,30 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
async def update_ticket_status(
|
async def update_ticket_status(
|
||||||
self, ticket_id: str, status_id: int
|
self, ticket_id: str, status_id: int
|
||||||
) -> PSATicket:
|
) -> PSATicket:
|
||||||
"""Update a CW ticket's status using JSON Patch format."""
|
"""Update a CW ticket's status using JSON Patch format.
|
||||||
|
|
||||||
|
Verifies CW actually applied the change — CW silently returns 200 when
|
||||||
|
a status id is invalid for the ticket's board. We check the response
|
||||||
|
body's status.id matches what we sent, and raise PSAError if not.
|
||||||
|
"""
|
||||||
patch_body = [
|
patch_body = [
|
||||||
{"op": "replace", "path": "status", "value": {"id": status_id}}
|
{"op": "replace", "path": "status", "value": {"id": status_id}}
|
||||||
]
|
]
|
||||||
data = await self.client.patch(
|
data = await self.client.patch(
|
||||||
f"/service/tickets/{ticket_id}", json_body=patch_body
|
f"/service/tickets/{ticket_id}", json_body=patch_body
|
||||||
)
|
)
|
||||||
|
applied = (data.get("status") or {}) if isinstance(data, dict) else {}
|
||||||
|
applied_id = applied.get("id")
|
||||||
|
if applied_id != status_id:
|
||||||
|
logger.warning(
|
||||||
|
"CW status PATCH for ticket %s returned status id=%s instead of %s",
|
||||||
|
ticket_id, applied_id, status_id,
|
||||||
|
)
|
||||||
|
raise PSAError(
|
||||||
|
f"ConnectWise did not apply status {status_id} "
|
||||||
|
f"(still {applied.get('name') or applied_id}). "
|
||||||
|
"The status may not be valid for this ticket's board."
|
||||||
|
)
|
||||||
return self._map_ticket(data)
|
return self._map_ticket(data)
|
||||||
|
|
||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
@@ -591,16 +628,247 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _map_ticket(data: dict) -> PSATicket:
|
def _map_ticket(data: dict) -> PSATicket:
|
||||||
"""Map a CW ticket JSON dict to a PSATicket."""
|
"""Map a CW ticket JSON dict to a PSATicket."""
|
||||||
|
company = data.get("company") or {}
|
||||||
|
board = data.get("board") or {}
|
||||||
|
status = data.get("status") or {}
|
||||||
|
priority = data.get("priority") or {}
|
||||||
return PSATicket(
|
return PSATicket(
|
||||||
id=str(data["id"]),
|
id=str(data.get("id", "")),
|
||||||
summary=data.get("summary", ""),
|
summary=data.get("summary", ""),
|
||||||
company_name=data.get("company", {}).get("name"),
|
company_name=company.get("name"),
|
||||||
company_id=str(data["company"]["id"]) if data.get("company") else None,
|
company_id=str(company.get("id")) if company.get("id") else None,
|
||||||
board_name=data.get("board", {}).get("name"),
|
board_name=board.get("name"),
|
||||||
board_id=data.get("board", {}).get("id"),
|
board_id=board.get("id"),
|
||||||
status_name=data.get("status", {}).get("name"),
|
status_name=status.get("name"),
|
||||||
status_id=data.get("status", {}).get("id"),
|
status_id=status.get("id"),
|
||||||
priority_name=data.get("priority", {}).get("name"),
|
priority_name=priority.get("name"),
|
||||||
priority_id=data.get("priority", {}).get("id"),
|
priority_id=priority.get("id"),
|
||||||
closed=data.get("closedFlag", False),
|
closed=data.get("closedFlag", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Resource management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
# Schedule type id for "Service Ticket" resources — CW's canonical type for ticket co-assignees
|
||||||
|
_SCHEDULE_TYPE_SERVICE_TICKET = 4
|
||||||
|
|
||||||
|
async def _get_ticket_owner(self, ticket_id: int) -> dict | None:
|
||||||
|
"""Fetch the ticket's current owner (MemberReference) or None if unassigned."""
|
||||||
|
data = await self.client.get(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
params={"fields": "id,owner"},
|
||||||
|
)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
owner_raw = data.get("owner")
|
||||||
|
return owner_raw if isinstance(owner_raw, dict) and owner_raw.get("id") else None
|
||||||
|
|
||||||
|
async def _list_ticket_schedule_entries(self, ticket_id: int) -> list[dict]:
|
||||||
|
"""List schedule entries for a ticket's co-assignees.
|
||||||
|
|
||||||
|
Returns raw CW schedule entry dicts with at least id and member info.
|
||||||
|
"""
|
||||||
|
data = await self.client.get(
|
||||||
|
"/schedule/entries",
|
||||||
|
params={
|
||||||
|
"conditions": (
|
||||||
|
f"type/id={self._SCHEDULE_TYPE_SERVICE_TICKET} AND objectId={ticket_id}"
|
||||||
|
),
|
||||||
|
"fields": "id,member,name",
|
||||||
|
"pageSize": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
"""List members assigned to a CW ticket.
|
||||||
|
|
||||||
|
Merges the `owner` MemberReference (primary assignee) with schedule entries
|
||||||
|
of type 4 (Service Ticket resources — co-assignees). Deduped by member id.
|
||||||
|
"""
|
||||||
|
owner = await self._get_ticket_owner(ticket_id)
|
||||||
|
entries = await self._list_ticket_schedule_entries(ticket_id)
|
||||||
|
members = await self.list_members()
|
||||||
|
by_id = {str(m.id): m for m in members}
|
||||||
|
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
results: list[PSAResource] = []
|
||||||
|
|
||||||
|
if owner is not None:
|
||||||
|
owner_id = str(owner.get("id"))
|
||||||
|
m = by_id.get(owner_id)
|
||||||
|
if m:
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=int(m.id),
|
||||||
|
member_name=m.name,
|
||||||
|
member_identifier=m.identifier,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=int(owner.get("id") or 0),
|
||||||
|
member_name=str(owner.get("name") or ""),
|
||||||
|
member_identifier=str(owner.get("identifier") or ""),
|
||||||
|
))
|
||||||
|
seen_ids.add(owner_id)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
entry_member = entry.get("member") if isinstance(entry, dict) else None
|
||||||
|
if not isinstance(entry_member, dict):
|
||||||
|
continue
|
||||||
|
mid = str(entry_member.get("id") or "")
|
||||||
|
if not mid or mid in seen_ids:
|
||||||
|
continue
|
||||||
|
m = by_id.get(mid)
|
||||||
|
if m:
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=int(m.id),
|
||||||
|
member_name=m.name,
|
||||||
|
member_identifier=m.identifier,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
results.append(PSAResource(
|
||||||
|
member_id=int(entry_member.get("id") or 0),
|
||||||
|
member_name=str(entry_member.get("name") or ""),
|
||||||
|
member_identifier=str(entry_member.get("identifier") or ""),
|
||||||
|
))
|
||||||
|
seen_ids.add(mid)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
"""Assign a member to a CW ticket.
|
||||||
|
|
||||||
|
- If the ticket has no owner, set the target as `owner` (CW's canonical
|
||||||
|
primary assignee field). CW typically mirrors this into the derived
|
||||||
|
`resources` string automatically.
|
||||||
|
- If the ticket is already owned by someone else, add the target as a
|
||||||
|
co-assignee via a schedule entry of type 4 (Service Ticket). The
|
||||||
|
existing owner is not changed.
|
||||||
|
- Idempotent when target is already owner or already has a schedule entry.
|
||||||
|
"""
|
||||||
|
members = await self.list_members()
|
||||||
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||||
|
if target is None:
|
||||||
|
raise PSAError(f"Member {member_id} not found")
|
||||||
|
|
||||||
|
current_owner = await self._get_ticket_owner(ticket_id)
|
||||||
|
|
||||||
|
if current_owner is None:
|
||||||
|
# Primary assign — set owner
|
||||||
|
await self.client.patch(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}],
|
||||||
|
)
|
||||||
|
elif str(current_owner.get("id")) != str(target.id):
|
||||||
|
# Ticket owned by someone else — add as co-assignee via schedule entry.
|
||||||
|
# Idempotent: skip if a schedule entry already exists for this member.
|
||||||
|
existing = await self._list_ticket_schedule_entries(ticket_id)
|
||||||
|
already_assigned = any(
|
||||||
|
str((e.get("member") or {}).get("id") or "") == str(target.id)
|
||||||
|
for e in existing
|
||||||
|
)
|
||||||
|
if not already_assigned:
|
||||||
|
await self.client.post(
|
||||||
|
"/schedule/entries",
|
||||||
|
json_body={
|
||||||
|
"member": {"id": int(target.id)},
|
||||||
|
"objectId": int(ticket_id),
|
||||||
|
"type": {"id": self._SCHEDULE_TYPE_SERVICE_TICKET},
|
||||||
|
"name": target.name or target.identifier or f"Member {target.id}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# else: already the owner — idempotent no-op
|
||||||
|
|
||||||
|
return PSAResource(
|
||||||
|
member_id=int(target.id),
|
||||||
|
member_name=target.name,
|
||||||
|
member_identifier=target.identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
"""Remove a member from a CW ticket (idempotent).
|
||||||
|
|
||||||
|
- If the target is the current owner, clear the owner field.
|
||||||
|
- Otherwise, delete their schedule entry (Service Ticket type).
|
||||||
|
"""
|
||||||
|
members = await self.list_members()
|
||||||
|
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||||
|
if target is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_owner = await self._get_ticket_owner(ticket_id)
|
||||||
|
|
||||||
|
if current_owner is not None and str(current_owner.get("id")) == str(target.id):
|
||||||
|
# Unassign the owner. Try RFC 6902 "remove" first; fall back to
|
||||||
|
# "replace" with null if CW rejects it.
|
||||||
|
try:
|
||||||
|
await self.client.patch(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
json_body=[{"op": "remove", "path": "owner"}],
|
||||||
|
)
|
||||||
|
except PSAError:
|
||||||
|
await self.client.patch(
|
||||||
|
f"/service/tickets/{ticket_id}",
|
||||||
|
json_body=[{"op": "replace", "path": "owner", "value": None}],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Not the owner — find and delete the schedule entry for this member.
|
||||||
|
entries = await self._list_ticket_schedule_entries(ticket_id)
|
||||||
|
for entry in entries:
|
||||||
|
entry_member = entry.get("member") if isinstance(entry, dict) else None
|
||||||
|
if isinstance(entry_member, dict) and str(entry_member.get("id") or "") == str(target.id):
|
||||||
|
entry_id = entry.get("id")
|
||||||
|
if entry_id:
|
||||||
|
await self.client.delete(f"/schedule/entries/{entry_id}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Ticket creation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
"""Create a new CW service ticket."""
|
||||||
|
body: dict = {
|
||||||
|
"summary": payload.summary,
|
||||||
|
"board": {"id": payload.board_id},
|
||||||
|
"company": {"id": payload.company_id},
|
||||||
|
"status": {"id": payload.status_id},
|
||||||
|
"priority": {"id": payload.priority_id},
|
||||||
|
}
|
||||||
|
if payload.description:
|
||||||
|
body["initialDescription"] = payload.description
|
||||||
|
if payload.assigned_member_id:
|
||||||
|
body["owner"] = {"id": payload.assigned_member_id}
|
||||||
|
|
||||||
|
data = await self.client.post("/service/tickets", json_body=body)
|
||||||
|
|
||||||
|
ticket_id = data.get("id") if isinstance(data, dict) else None
|
||||||
|
resources: list[PSAResource] = []
|
||||||
|
if ticket_id and payload.assigned_member_id:
|
||||||
|
try:
|
||||||
|
resources = await self.list_resources(ticket_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
company = (data.get("company") or {}) if isinstance(data, dict) else {}
|
||||||
|
board = (data.get("board") or {}) if isinstance(data, dict) else {}
|
||||||
|
status = (data.get("status") or {}) if isinstance(data, dict) else {}
|
||||||
|
priority = (data.get("priority") or {}) if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
return PSACreatedTicket(
|
||||||
|
id=ticket_id or 0,
|
||||||
|
summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary,
|
||||||
|
board_name=board.get("name", ""),
|
||||||
|
status_name=status.get("name", ""),
|
||||||
|
priority_name=priority.get("name", ""),
|
||||||
|
company_name=company.get("name", ""),
|
||||||
|
resources=resources,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Priorities ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
"""List CW service priorities."""
|
||||||
|
data = await self.client.get("/service/priorities", params={"pageSize": 50})
|
||||||
|
return [
|
||||||
|
{"id": p.get("id"), "name": p.get("name")}
|
||||||
|
for p in (data if isinstance(data, list) else [])
|
||||||
|
]
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from app.services.psa.types import (
|
|||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
PSABoard,
|
PSABoard,
|
||||||
|
PaginatedTicketResult,
|
||||||
|
PSAResource,
|
||||||
|
PSACreatedTicket,
|
||||||
|
TicketCreatePayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ class HaloPSAProvider(PSAProvider):
|
|||||||
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
async def post_note(
|
async def post_note(
|
||||||
@@ -74,3 +78,18 @@ class HaloPSAProvider(PSAProvider):
|
|||||||
work_type: str | None = None,
|
work_type: str | None = None,
|
||||||
) -> PSATimeEntry:
|
) -> PSATimeEntry:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
async def list_priorities(self) -> list[dict]:
|
||||||
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|||||||
@@ -73,6 +73,40 @@ class PSABoard(BaseModel):
|
|||||||
inactive: bool = False
|
inactive: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedTicketResult(BaseModel):
|
||||||
|
items: list[PSATicket]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class PSAResource(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
member_name: str
|
||||||
|
member_identifier: str
|
||||||
|
is_rf_user: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PSACreatedTicket(BaseModel):
|
||||||
|
id: int
|
||||||
|
summary: str
|
||||||
|
board_name: str
|
||||||
|
status_name: str
|
||||||
|
priority_name: str
|
||||||
|
company_name: str
|
||||||
|
resources: list[PSAResource] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreatePayload(BaseModel):
|
||||||
|
summary: str
|
||||||
|
company_id: int
|
||||||
|
board_id: int
|
||||||
|
status_id: int
|
||||||
|
priority_id: int
|
||||||
|
description: str | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class NoteType:
|
class NoteType:
|
||||||
INTERNAL_ANALYSIS = "internal_analysis"
|
INTERNAL_ANALYSIS = "internal_analysis"
|
||||||
RESOLUTION = "resolution"
|
RESOLUTION = "resolution"
|
||||||
|
|||||||
223
backend/app/services/psa_writeback_service.py
Normal file
223
backend/app/services/psa_writeback_service.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""PSA writeback for FlowPilot Phase 4 — Resolve + Escalate round-trip.
|
||||||
|
|
||||||
|
Three primitives:
|
||||||
|
|
||||||
|
- `post_resolution_note` — post the engineer-edited resolution markdown to
|
||||||
|
the PSA ticket, store `{external_id, posted_at}` on the session.
|
||||||
|
- `post_escalation_package` — same pattern for the Escalate flow.
|
||||||
|
- `transition_ticket_status` — patch the ticket status, then re-fetch and
|
||||||
|
verify the change actually took. Failed verification raises loudly so the
|
||||||
|
UI never reports silent success (per the existing ConnectWise integration
|
||||||
|
principle called out in FLOWPILOT-MIGRATION.md Section 6.5 and CLAUDE.md).
|
||||||
|
|
||||||
|
The target status IDs live in `account_settings.preferences`
|
||||||
|
(`cw_resolved_status_id`, `cw_escalated_status_id`). When unset, the status
|
||||||
|
transition is a no-op and the endpoint response says so — we do not guess a
|
||||||
|
default because CW status IDs are board-specific.
|
||||||
|
|
||||||
|
Local-only path: callers handle sessions without `psa_ticket_id` before
|
||||||
|
calling this service. Nothing here tries to "post locally" — the service's
|
||||||
|
job ends at the PSA boundary.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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.account_settings import AccountSettings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.services.psa.exceptions import PSAConnectionError
|
||||||
|
from app.services.psa.registry import get_provider_for_connection
|
||||||
|
from app.services.psa.types import NoteType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PSAStatusVerificationError(RuntimeError):
|
||||||
|
"""Raised when a ticket status transition didn't stick on re-fetch.
|
||||||
|
|
||||||
|
The `update_ticket_status` call returned OK but the subsequent
|
||||||
|
`get_ticket` still shows the prior status (or some unrelated one).
|
||||||
|
This is the exact failure mode CLAUDE.md flags as a ConnectWise
|
||||||
|
anti-pattern: reporting success when nothing changed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ticket_id: str, expected_status_id: int, observed_status: Any) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"Ticket {ticket_id} status transition to {expected_status_id} "
|
||||||
|
f"did not verify — observed {observed_status!r} after re-fetch."
|
||||||
|
)
|
||||||
|
self.ticket_id = ticket_id
|
||||||
|
self.expected_status_id = expected_status_id
|
||||||
|
self.observed_status = observed_status
|
||||||
|
|
||||||
|
|
||||||
|
class PSAWritebackService:
|
||||||
|
"""Thin orchestration over the PSA provider for FlowPilot writebacks.
|
||||||
|
|
||||||
|
Instances are per-request — the AsyncSession is the one handling the
|
||||||
|
current HTTP call, and the provider is resolved lazily from the session's
|
||||||
|
`psa_connection_id`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def post_resolution_note(
|
||||||
|
self, session: AISession, markdown: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Post `markdown` as a resolution note on the linked CW ticket.
|
||||||
|
|
||||||
|
On success, persists `resolution_note_markdown`, `_posted_at`,
|
||||||
|
`_external_id` on the session and returns the same triple. Caller is
|
||||||
|
responsible for committing the transaction.
|
||||||
|
"""
|
||||||
|
return await self._post_note(
|
||||||
|
session=session,
|
||||||
|
markdown=markdown,
|
||||||
|
note_type=NoteType.RESOLUTION,
|
||||||
|
markdown_col="resolution_note_markdown",
|
||||||
|
posted_at_col="resolution_note_posted_at",
|
||||||
|
external_id_col="resolution_note_external_id",
|
||||||
|
kind="resolution",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post_escalation_package(
|
||||||
|
self, session: AISession, markdown: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Post `markdown` as an escalation handoff note on the CW ticket."""
|
||||||
|
return await self._post_note(
|
||||||
|
session=session,
|
||||||
|
markdown=markdown,
|
||||||
|
# Internal-analysis visibility: the handoff is for the next engineer,
|
||||||
|
# not the customer. CW fires no notifications, keeps the note internal.
|
||||||
|
note_type=NoteType.INTERNAL_ANALYSIS,
|
||||||
|
markdown_col="escalation_package_markdown",
|
||||||
|
posted_at_col="escalation_package_posted_at",
|
||||||
|
external_id_col="escalation_package_external_id",
|
||||||
|
kind="escalation",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def transition_ticket_status(
|
||||||
|
self,
|
||||||
|
session: AISession,
|
||||||
|
target_status_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""PATCH ticket status, then re-fetch and verify.
|
||||||
|
|
||||||
|
Returns `{"success": True, "verified_status_id": <int>, "verified_status_name": <str>}`
|
||||||
|
when the observed status matches. Raises `PSAStatusVerificationError`
|
||||||
|
when the transition didn't take (most common real-world failure: CW
|
||||||
|
requires certain fields before allowing a status change to
|
||||||
|
Resolved — the PATCH returns 200 but the status silently stays put).
|
||||||
|
"""
|
||||||
|
if not session.psa_ticket_id or not session.psa_connection_id:
|
||||||
|
raise ValueError("Session has no linked PSA ticket for status transition")
|
||||||
|
|
||||||
|
provider = await get_provider_for_connection(session.psa_connection_id, self.db)
|
||||||
|
await provider.update_ticket_status(
|
||||||
|
ticket_id=session.psa_ticket_id, status_id=target_status_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify by re-fetch — this is the load-bearing step.
|
||||||
|
verification = await provider.get_ticket(session.psa_ticket_id)
|
||||||
|
observed_id = getattr(verification, "status_id", None)
|
||||||
|
observed_name = getattr(verification, "status_name", None)
|
||||||
|
if observed_id != target_status_id:
|
||||||
|
raise PSAStatusVerificationError(
|
||||||
|
ticket_id=session.psa_ticket_id,
|
||||||
|
expected_status_id=target_status_id,
|
||||||
|
observed_status={"id": observed_id, "name": observed_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"verified_status_id": observed_id,
|
||||||
|
"verified_status_name": observed_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def resolved_status_id_for_account(
|
||||||
|
self, account_id: UUID
|
||||||
|
) -> int | None:
|
||||||
|
"""Return the configured CW "Resolved" status ID for the account.
|
||||||
|
|
||||||
|
None means "no transition configured" — callers should skip the
|
||||||
|
transition (posting the note is still meaningful). This lives in
|
||||||
|
account_settings.preferences per the Phase 1 JSONB grab-bag design.
|
||||||
|
"""
|
||||||
|
raw = await AccountSettings.get_setting(self.db, account_id, "cw_resolved_status_id", None)
|
||||||
|
return self._coerce_status_id(raw)
|
||||||
|
|
||||||
|
async def escalated_status_id_for_account(
|
||||||
|
self, account_id: UUID
|
||||||
|
) -> int | None:
|
||||||
|
raw = await AccountSettings.get_setting(self.db, account_id, "cw_escalated_status_id", None)
|
||||||
|
return self._coerce_status_id(raw)
|
||||||
|
|
||||||
|
# ── Internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _post_note(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AISession,
|
||||||
|
markdown: str,
|
||||||
|
note_type: str,
|
||||||
|
markdown_col: str,
|
||||||
|
posted_at_col: str,
|
||||||
|
external_id_col: str,
|
||||||
|
kind: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not session.psa_ticket_id or not session.psa_connection_id:
|
||||||
|
raise ValueError(f"Session has no linked PSA ticket for {kind} post")
|
||||||
|
|
||||||
|
markdown = (markdown or "").strip()
|
||||||
|
if not markdown:
|
||||||
|
raise ValueError(f"{kind} markdown is empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = await get_provider_for_connection(session.psa_connection_id, self.db)
|
||||||
|
except PSAConnectionError:
|
||||||
|
# Connection could have been deleted or deactivated since session
|
||||||
|
# creation — propagate as a clear error for the endpoint to surface.
|
||||||
|
logger.exception(
|
||||||
|
"PSA connection %s is no longer available for session %s",
|
||||||
|
session.psa_connection_id, session.id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
posted = await provider.post_note(
|
||||||
|
ticket_id=session.psa_ticket_id,
|
||||||
|
text=markdown,
|
||||||
|
note_type=note_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
posted_at = datetime.now(timezone.utc)
|
||||||
|
setattr(session, markdown_col, markdown)
|
||||||
|
setattr(session, posted_at_col, posted_at)
|
||||||
|
setattr(session, external_id_col, str(posted.id) if posted.id else None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"external_id": str(posted.id) if posted.id else None,
|
||||||
|
"posted_at": posted_at,
|
||||||
|
"kind": kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_status_id(raw: Any) -> int | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Non-integer CW status ID in account_settings.preferences: %r",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return None
|
||||||
342
backend/app/services/resolution_note_generator.py
Normal file
342
backend/app/services/resolution_note_generator.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
"""ResolutionNoteGeneratorService — drafts the structured Resolve note for a session.
|
||||||
|
|
||||||
|
Produces the four-section markdown that ships to the customer ticket (per
|
||||||
|
FLOWPILOT-MIGRATION.md Section 6.2):
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
## What we confirmed
|
||||||
|
## Root cause
|
||||||
|
## Resolution
|
||||||
|
|
||||||
|
The output is the *draft* — engineers review and edit in the preview popover
|
||||||
|
before clicking Confirm & post (Phase 4). Caching is keyed on
|
||||||
|
`(session_id, ai_sessions.state_version)` per Section 5.5; the cache lives in
|
||||||
|
`preview_cache` and invalidates automatically when any fact / suggested fix /
|
||||||
|
script generation bumps the session's state_version.
|
||||||
|
|
||||||
|
Model: Sonnet (`resolution_note` action tier — quality matters because the
|
||||||
|
output is customer-facing). MCP intentionally disabled — this is a summary
|
||||||
|
of existing state, not a research task.
|
||||||
|
|
||||||
|
Sensitive parameter values in script_generations are redacted using the
|
||||||
|
script template's `parameters_schema` (`field_type: "password"`). Existing
|
||||||
|
ScriptTemplateEngine.redact_sensitive handles the substitution.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.script_template import ScriptGeneration, ScriptTemplate
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
from app.services.preview_cache import preview_cache
|
||||||
|
from app.services.script_template_engine import ScriptTemplateEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_RESOLUTION_NOTE_SYSTEM_PROMPT = """\
|
||||||
|
You produce structured resolution notes for an MSP troubleshooting platform. \
|
||||||
|
The notes are posted as ticket notes in the customer's PSA, so they must read \
|
||||||
|
like a competent senior engineer summarized the work — not like an AI \
|
||||||
|
narration. Your output goes in front of paying customers.
|
||||||
|
|
||||||
|
Output exactly this markdown structure, no preamble, no closing remarks, no \
|
||||||
|
extra headings:
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
<one short paragraph stating the issue the engineer worked on, derived from the \
|
||||||
|
session's intake/title and the incident header. Past tense. No "user reported" \
|
||||||
|
hedging — state the problem directly.>
|
||||||
|
|
||||||
|
## What we confirmed
|
||||||
|
<bulleted list of facts from the "What we know" section, each one a short line. \
|
||||||
|
Group similar facts together; do not invent connecting prose. If there are no \
|
||||||
|
facts, write "Nothing was confirmed." and skip to Root cause.>
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
<one short paragraph naming the root cause based on the active suggested fix \
|
||||||
|
and confirmed facts. If the suggested fix is low-confidence (<60%) or absent, \
|
||||||
|
say "Root cause not definitively isolated." and explain what is suspected based \
|
||||||
|
on facts.>
|
||||||
|
|
||||||
|
## Resolution
|
||||||
|
<The content of this section depends on the outcome recorded for the active \
|
||||||
|
suggested fix, as given in the input bundle under "fix.status":>
|
||||||
|
|
||||||
|
- applied_success: Write in past tense using closure language. State that the \
|
||||||
|
fix was applied and verified as working. If verified_at is provided, you may \
|
||||||
|
reference it as the time resolution was confirmed. Example phrasing: \
|
||||||
|
"Applied <fix title>; confirmed working."
|
||||||
|
- applied_failed: Acknowledge that the proposed fix did not resolve the issue \
|
||||||
|
and was discarded. If failure_reason is provided, include it. Then describe \
|
||||||
|
the actual resolution path taken (derived from facts and scripts run). This \
|
||||||
|
state means the engineer resolved the issue another way; the note should cover \
|
||||||
|
that actual resolution, not just the failed attempt.
|
||||||
|
- applied_partial: Note that the fix was partially applied. If partial_notes \
|
||||||
|
are provided, include them. Then describe the final resolution path taken.
|
||||||
|
- dismissed: Treat the fix as considered and set aside. Do not center the note \
|
||||||
|
on it. Describe the resolution based on what was actually confirmed and done.
|
||||||
|
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
|
||||||
|
<fix title>." Pull verbatim script names and template references when available.
|
||||||
|
|
||||||
|
Strict rules:
|
||||||
|
- Use ONLY the facts and state I provide. Never invent specifics that are not \
|
||||||
|
in the input.
|
||||||
|
- Do not include placeholder text like "TBD", "TODO", or empty bullets.
|
||||||
|
- Do not include the engineer's name, the AI's name, internal session IDs, or \
|
||||||
|
the session's chat transcript.
|
||||||
|
- Markdown headings exactly as shown (## level), no bolding the headings.
|
||||||
|
- No trailing whitespace, no double-blank lines, no horizontal rules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionNoteGeneratorService:
|
||||||
|
"""Generates and caches the four-section Resolve note markdown."""
|
||||||
|
|
||||||
|
KIND = "resolution_note"
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def generate_or_get_cached(
|
||||||
|
self, session_id: UUID, *, force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the preview for the session.
|
||||||
|
|
||||||
|
Reads `(KIND, session_id, state_version)` from the in-process cache;
|
||||||
|
on miss, generates fresh markdown and stores under the same key.
|
||||||
|
`force=True` bypasses the cache and refreshes the cached entry.
|
||||||
|
|
||||||
|
Returns `{"markdown": str, "target_ticket_ref": str | None,
|
||||||
|
"state_version": int, "from_cache": bool}`.
|
||||||
|
"""
|
||||||
|
session = await self._load_session(session_id)
|
||||||
|
cached = preview_cache.get(self.KIND, session.id, session.state_version) if not force else None
|
||||||
|
if cached is not None:
|
||||||
|
return {**cached, "from_cache": True}
|
||||||
|
|
||||||
|
markdown = await self._render(session)
|
||||||
|
target = self._target_ticket_ref(session)
|
||||||
|
payload = {
|
||||||
|
"markdown": markdown,
|
||||||
|
"target_ticket_ref": target,
|
||||||
|
"state_version": session.state_version,
|
||||||
|
}
|
||||||
|
preview_cache.set(self.KIND, session.id, session.state_version, payload)
|
||||||
|
return {**payload, "from_cache": False}
|
||||||
|
|
||||||
|
# ── Internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _load_session(self, session_id: UUID) -> AISession:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AISession).where(AISession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None:
|
||||||
|
raise ValueError(f"Session {session_id} not found")
|
||||||
|
return session
|
||||||
|
|
||||||
|
async def _render(self, session: AISession) -> str:
|
||||||
|
"""Build the prompt input bundle, call the model, return markdown."""
|
||||||
|
facts = await self._load_facts(session.id)
|
||||||
|
active_fix = await self._load_active_fix(session.id)
|
||||||
|
gens = await self._load_redacted_generations(session.id)
|
||||||
|
|
||||||
|
bundle = self._build_input_bundle(session, facts, active_fix, gens)
|
||||||
|
|
||||||
|
model = settings.get_model_for_action("resolution_note")
|
||||||
|
provider = get_ai_provider(model=model)
|
||||||
|
|
||||||
|
# Cache the system prompt — identical across every preview call for
|
||||||
|
# every session. Per-session bundle is in the user message, uncached.
|
||||||
|
system_blocks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": _RESOLUTION_NOTE_SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
# cacheable: identical across every resolution-note preview call
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, _in, _out = await provider.generate_text(
|
||||||
|
system_prompt=system_blocks,
|
||||||
|
messages=[{"role": "user", "content": bundle}],
|
||||||
|
max_tokens=1200,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Resolution note generation failed for session %s", session.id)
|
||||||
|
raise
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
async def _load_facts(self, session_id: UUID) -> list[SessionFact]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionFact)
|
||||||
|
.where(
|
||||||
|
SessionFact.session_id == session_id,
|
||||||
|
SessionFact.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionFact.created_at.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _load_active_fix(self, session_id: UUID) -> SessionSuggestedFix | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.session_id == session_id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SessionSuggestedFix.created_at.desc())
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def _load_redacted_generations(
|
||||||
|
self, session_id: UUID
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Pull script_generations for the session, redacting password params.
|
||||||
|
|
||||||
|
Password fields are inferred from the linked template's
|
||||||
|
`parameters_schema` (`field_type: "password"`). The existing
|
||||||
|
ScriptTemplateEngine.redact_sensitive handles the substitution.
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ScriptGeneration)
|
||||||
|
.where(ScriptGeneration.ai_session_id == session_id)
|
||||||
|
.order_by(ScriptGeneration.created_at.asc())
|
||||||
|
)
|
||||||
|
gens = list(result.scalars().all())
|
||||||
|
if not gens:
|
||||||
|
return []
|
||||||
|
|
||||||
|
template_ids = {g.template_id for g in gens}
|
||||||
|
tpl_result = await self.db.execute(
|
||||||
|
select(ScriptTemplate).where(ScriptTemplate.id.in_(template_ids))
|
||||||
|
)
|
||||||
|
templates_by_id = {t.id: t for t in tpl_result.scalars().all()}
|
||||||
|
|
||||||
|
engine = ScriptTemplateEngine()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for g in gens:
|
||||||
|
tpl = templates_by_id.get(g.template_id)
|
||||||
|
sensitive_keys = self._sensitive_keys_from_schema(
|
||||||
|
(tpl.parameters_schema if tpl else {}) or {}
|
||||||
|
)
|
||||||
|
redacted_params = engine.redact_sensitive(g.parameters_used or {}, sensitive_keys)
|
||||||
|
out.append({
|
||||||
|
"template_name": tpl.name if tpl else "(unknown template)",
|
||||||
|
"template_slug": tpl.slug if tpl else None,
|
||||||
|
"parameters_used": redacted_params,
|
||||||
|
"created_at": g.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sensitive_keys_from_schema(schema: dict[str, Any]) -> set[str]:
|
||||||
|
"""Extract password-typed parameter keys from a template's schema.
|
||||||
|
|
||||||
|
The schema shape is `{"parameters": [{"key": "...", "field_type": "password", ...}]}`
|
||||||
|
per the existing Script Generator convention. Tolerate both that shape
|
||||||
|
and the simpler `{"key": {"field_type": "password"}}` form.
|
||||||
|
"""
|
||||||
|
keys: set[str] = set()
|
||||||
|
params = schema.get("parameters") if isinstance(schema, dict) else None
|
||||||
|
if isinstance(params, list):
|
||||||
|
for p in params:
|
||||||
|
if isinstance(p, dict) and p.get("field_type") == "password":
|
||||||
|
k = p.get("key") or p.get("variable_name")
|
||||||
|
if isinstance(k, str):
|
||||||
|
keys.add(k)
|
||||||
|
elif isinstance(schema, dict):
|
||||||
|
for k, v in schema.items():
|
||||||
|
if isinstance(v, dict) and v.get("field_type") == "password":
|
||||||
|
keys.add(k)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _target_ticket_ref(session: AISession) -> str | None:
|
||||||
|
"""Display ref for the linked PSA ticket, e.g. 'CW #48291'.
|
||||||
|
|
||||||
|
ConnectWise is the only PSA wired today (per the Phase 1 constraint),
|
||||||
|
so a CW prefix is reasonable. Other PSAs will need provider-aware
|
||||||
|
formatting in Phase 4.
|
||||||
|
"""
|
||||||
|
if not session.psa_ticket_id:
|
||||||
|
return None
|
||||||
|
return f"CW #{session.psa_ticket_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_input_bundle(
|
||||||
|
session: AISession,
|
||||||
|
facts: list[SessionFact],
|
||||||
|
active_fix: SessionSuggestedFix | None,
|
||||||
|
generations: list[dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""Compose the structured input the LLM sees for one preview call."""
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("# Session context")
|
||||||
|
lines.append(f"Title: {session.title or '(untitled)'}")
|
||||||
|
if session.problem_summary:
|
||||||
|
lines.append(f"Problem summary: {session.problem_summary}")
|
||||||
|
if session.problem_domain:
|
||||||
|
lines.append(f"Domain: {session.problem_domain}")
|
||||||
|
intake_text = (session.intake_content or {}).get("text") if isinstance(session.intake_content, dict) else None
|
||||||
|
if intake_text:
|
||||||
|
lines.append(f"Intake message: {intake_text}")
|
||||||
|
if session.psa_ticket_id:
|
||||||
|
lines.append(f"Linked PSA ticket: CW #{session.psa_ticket_id}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Confirmed facts (What we know)")
|
||||||
|
if not facts:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for f in facts:
|
||||||
|
tag = f.source_type
|
||||||
|
summary = f" — {f.source_summary}" if f.source_summary else ""
|
||||||
|
lines.append(f"- [{tag}] {f.text}{summary}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Active suggested fix")
|
||||||
|
if active_fix is None:
|
||||||
|
lines.append("(no active suggested fix)")
|
||||||
|
else:
|
||||||
|
lines.append(f"Title: {active_fix.title}")
|
||||||
|
lines.append(f"Confidence: {active_fix.confidence_pct}%")
|
||||||
|
lines.append(f"Description: {active_fix.description}")
|
||||||
|
if active_fix.user_decision:
|
||||||
|
lines.append(f"Engineer decision: {active_fix.user_decision}")
|
||||||
|
lines.append(f"Outcome status: {active_fix.status}")
|
||||||
|
if active_fix.applied_at:
|
||||||
|
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
|
||||||
|
if active_fix.verified_at:
|
||||||
|
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||||
|
if active_fix.partial_notes:
|
||||||
|
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||||
|
if active_fix.failure_reason:
|
||||||
|
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("# Scripts run during the session (passwords redacted)")
|
||||||
|
if not generations:
|
||||||
|
lines.append("(none)")
|
||||||
|
else:
|
||||||
|
for g in generations:
|
||||||
|
lines.append(f"- {g['template_name']} (slug={g['template_slug']})")
|
||||||
|
if g["parameters_used"]:
|
||||||
|
lines.append(f" parameters: {g['parameters_used']}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Produce the four-section resolution note now. Use only the input above."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -148,6 +148,8 @@ async def create_session(
|
|||||||
team_id: UUID | None,
|
team_id: UUID | None,
|
||||||
language: str,
|
language: str,
|
||||||
initial_prompt: str | None = None,
|
initial_prompt: str | None = None,
|
||||||
|
origin: str = "standalone",
|
||||||
|
ai_session_id: UUID | None = None,
|
||||||
) -> ScriptBuilderSession:
|
) -> ScriptBuilderSession:
|
||||||
"""Create a new Script Builder session."""
|
"""Create a new Script Builder session."""
|
||||||
session = ScriptBuilderSession(
|
session = ScriptBuilderSession(
|
||||||
@@ -155,6 +157,8 @@ async def create_session(
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
language=language,
|
language=language,
|
||||||
|
origin=origin,
|
||||||
|
ai_session_id=ai_session_id,
|
||||||
)
|
)
|
||||||
db.add(session)
|
db.add(session)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -295,15 +299,22 @@ async def list_sessions(
|
|||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
*,
|
||||||
|
include_inline: bool = False,
|
||||||
) -> list[ScriptBuilderSession]:
|
) -> list[ScriptBuilderSession]:
|
||||||
"""List user's builder sessions ordered by updated_at desc."""
|
"""List user's builder sessions ordered by updated_at desc.
|
||||||
result = await db.execute(
|
|
||||||
|
By default (include_inline=False) excludes pilot_inline sessions so the
|
||||||
|
/script-builder dashboard only shows standalone sessions.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
select(ScriptBuilderSession)
|
select(ScriptBuilderSession)
|
||||||
.where(ScriptBuilderSession.user_id == user_id)
|
.where(ScriptBuilderSession.user_id == user_id)
|
||||||
.order_by(ScriptBuilderSession.updated_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
)
|
)
|
||||||
|
if not include_inline:
|
||||||
|
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||||||
|
stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc()).limit(limit).offset(offset)
|
||||||
|
result = await db.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@@ -321,13 +332,23 @@ async def delete_session(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def count_user_sessions(db: AsyncSession, user_id: UUID) -> int:
|
async def count_user_sessions(
|
||||||
"""Count active builder sessions for a user."""
|
db: AsyncSession,
|
||||||
result = await db.execute(
|
user_id: UUID,
|
||||||
select(func.count(ScriptBuilderSession.id)).where(
|
*,
|
||||||
|
include_inline: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Count active builder sessions for a user.
|
||||||
|
|
||||||
|
By default (include_inline=False) excludes pilot_inline sessions so they
|
||||||
|
don't consume slots against the MAX_SESSIONS_PER_USER cap.
|
||||||
|
"""
|
||||||
|
stmt = select(func.count(ScriptBuilderSession.id)).where(
|
||||||
ScriptBuilderSession.user_id == user_id
|
ScriptBuilderSession.user_id == user_id
|
||||||
)
|
)
|
||||||
)
|
if not include_inline:
|
||||||
|
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||||||
|
result = await db.execute(stmt)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
201
backend/app/services/template_extraction_service.py
Normal file
201
backend/app/services/template_extraction_service.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""TemplateExtractionService — propose a parameter schema from a rendered script.
|
||||||
|
|
||||||
|
Phase 5 of the FlowPilot migration. Called when an engineer chooses
|
||||||
|
"Run now, templatize after resolve" on a suggested fix with no existing
|
||||||
|
library match. The service looks at the concrete script (with the values
|
||||||
|
the engineer is about to run with) and session/ticket context, then
|
||||||
|
proposes a parameterization that future engineers could use from the
|
||||||
|
Script Library.
|
||||||
|
|
||||||
|
Design choices (per FLOWPILOT-MIGRATION.md Section 6.4):
|
||||||
|
|
||||||
|
- **Conservative by default.** Prefer fewer parameters. Environment-agnostic
|
||||||
|
values (like a command name) should not be parameterized. The prompt calls
|
||||||
|
that out explicitly.
|
||||||
|
- **Round-trip check.** After the LLM proposes parameters, we validate that
|
||||||
|
the templated body renders back to the original script when given the
|
||||||
|
extracted parameter values. Failures log a warning and the caller falls
|
||||||
|
back to a single-parameter "raw script" proposal.
|
||||||
|
- **Model:** Sonnet (`template_extraction` tier). Creates a persistent
|
||||||
|
library artifact — quality matters more than latency.
|
||||||
|
|
||||||
|
Output shape mirrors the Script Generator's parameter schema:
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{"key": "<snake>", "label": "<human>", "type": "text|password|select|...",
|
||||||
|
"inferred_from": "<session fact / ticket field / ai guess>"}
|
||||||
|
],
|
||||||
|
"templated_body": "<script with {{ key }} placeholders>",
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.ai_provider import get_ai_provider
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_EXTRACTION_SYSTEM_PROMPT = """\
|
||||||
|
You are a senior MSP engineer drafting a reusable script template from a \
|
||||||
|
concrete script that resolved one ticket. Your job is to identify the values \
|
||||||
|
in the script that would change for a different invocation — those become \
|
||||||
|
parameters — and replace them with {{ snake_case }} placeholders.
|
||||||
|
|
||||||
|
Return strict JSON with this shape:
|
||||||
|
{
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"key": "<snake_case, ASCII>",
|
||||||
|
"label": "<Short human label, Title Case>",
|
||||||
|
"type": "text" | "password" | "select" | "boolean" | "number" | "textarea",
|
||||||
|
"inferred_from": "<short sentence naming the session fact or ticket \
|
||||||
|
field this value came from; or 'ai best-guess' when neither>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"templated_body": "<the original script with each parameterized value \
|
||||||
|
replaced by {{ key }} matching the parameters above>"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Prefer FEWER parameters. If a value looks environment-agnostic — a cmdlet \
|
||||||
|
name, a standard path like C:\\Windows\\System32, a Microsoft-documented URL \
|
||||||
|
— keep it hardcoded.
|
||||||
|
- Secret-looking values (passwords, API keys, client secrets) MUST be \
|
||||||
|
parameterized with type=password.
|
||||||
|
- The templated_body MUST render back to the original script when the \
|
||||||
|
parameter values from the context are substituted in. Preserve all whitespace, \
|
||||||
|
comments, and casing.
|
||||||
|
- If the script has no meaningful parameters (e.g. it's a single read-only \
|
||||||
|
cmdlet like Get-Service), return parameters=[] and templated_body = original.
|
||||||
|
- No markdown fences, no prose, only the JSON object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_parameters(
|
||||||
|
*,
|
||||||
|
script_body: str,
|
||||||
|
session_context: str | None = None,
|
||||||
|
ticket_context: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return `{parameters, templated_body}` for the given rendered script.
|
||||||
|
|
||||||
|
On LLM failure or malformed output, returns a conservative fallback:
|
||||||
|
the original body with no parameters proposed. Callers can still create
|
||||||
|
a `draft_templates` row from this — the engineer reviews and refines
|
||||||
|
before accepting in the post-resolve prompt (Phase 6).
|
||||||
|
"""
|
||||||
|
model = settings.get_model_for_action("template_extraction")
|
||||||
|
provider = get_ai_provider(model=model)
|
||||||
|
|
||||||
|
input_lines = [
|
||||||
|
"# Script to templatize",
|
||||||
|
"```",
|
||||||
|
script_body.strip(),
|
||||||
|
"```",
|
||||||
|
]
|
||||||
|
if session_context:
|
||||||
|
input_lines.extend(["", "# Session context (facts, symptoms)", session_context.strip()])
|
||||||
|
if ticket_context:
|
||||||
|
input_lines.extend(["", "# Ticket context (company, user, priority)", ticket_context.strip()])
|
||||||
|
user_input = "\n".join(input_lines)
|
||||||
|
|
||||||
|
system_blocks: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": _EXTRACTION_SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
# cacheable: identical across every extraction call
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
text, _in, _out = await provider.generate_json(
|
||||||
|
system_prompt=system_blocks,
|
||||||
|
messages=[{"role": "user", "content": user_input}],
|
||||||
|
max_tokens=3000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("TemplateExtractionService LLM call failed; returning fallback")
|
||||||
|
return _fallback(script_body)
|
||||||
|
|
||||||
|
parsed = _parse_response(text)
|
||||||
|
if parsed is None:
|
||||||
|
return _fallback(script_body)
|
||||||
|
|
||||||
|
# Round-trip validation: render parsed["templated_body"] with the
|
||||||
|
# `inferred_from` values and confirm it matches the original. We don't
|
||||||
|
# have the engineer's values yet here (those come at runtime), but we
|
||||||
|
# can at least check that every {{ key }} in templated_body maps to a
|
||||||
|
# declared parameter. A mismatch means the LLM referenced an undeclared
|
||||||
|
# placeholder — conservative fallback.
|
||||||
|
declared_keys = {p.get("key") for p in parsed["parameters"] if isinstance(p, dict)}
|
||||||
|
referenced_keys = set(re.findall(r"\{\{\s*(\w+)\s*\}\}", parsed["templated_body"]))
|
||||||
|
missing = referenced_keys - declared_keys
|
||||||
|
if missing:
|
||||||
|
logger.warning(
|
||||||
|
"TemplateExtractionService: templated_body references undeclared "
|
||||||
|
"keys %s; using fallback",
|
||||||
|
sorted(missing),
|
||||||
|
)
|
||||||
|
return _fallback(script_body)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(raw: str) -> dict[str, Any] | None:
|
||||||
|
"""Tolerant parse. Returns None on any structural problem."""
|
||||||
|
cleaned = raw.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||||||
|
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||||
|
try:
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
logger.warning("TemplateExtractionService returned non-JSON: %r", raw[:200])
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
params = data.get("parameters")
|
||||||
|
body = data.get("templated_body")
|
||||||
|
if not isinstance(params, list) or not isinstance(body, str):
|
||||||
|
logger.warning("TemplateExtractionService missing parameters or templated_body")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate each parameter shape. Drop malformed entries rather than
|
||||||
|
# failing the whole response — the engineer will review before accept.
|
||||||
|
valid_params: list[dict[str, Any]] = []
|
||||||
|
allowed_types = {"text", "password", "select", "boolean", "number", "textarea", "multi_text"}
|
||||||
|
for p in params:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
key = p.get("key")
|
||||||
|
if not isinstance(key, str) or not re.match(r"^[a-z_][a-z0-9_]*$", key):
|
||||||
|
continue
|
||||||
|
ptype = p.get("type", "text")
|
||||||
|
if ptype not in allowed_types:
|
||||||
|
ptype = "text"
|
||||||
|
valid_params.append({
|
||||||
|
"key": key,
|
||||||
|
"label": p.get("label") or key.replace("_", " ").title(),
|
||||||
|
"type": ptype,
|
||||||
|
"inferred_from": p.get("inferred_from") or "ai best-guess",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"parameters": valid_params, "templated_body": body}
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback(script_body: str) -> dict[str, Any]:
|
||||||
|
"""Conservative no-op result: zero parameters, body unchanged.
|
||||||
|
|
||||||
|
Used when the LLM call fails or returns unusable output. The engineer
|
||||||
|
can still save this as a draft and refine in the post-resolve prompt —
|
||||||
|
it just won't propose a parameterization for them.
|
||||||
|
"""
|
||||||
|
return {"parameters": [], "templated_body": script_body}
|
||||||
116
backend/app/services/ticket_service.py
Normal file
116
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.psa_connection import PsaConnection
|
||||||
|
from app.models.psa_member_mapping import PsaMemberMapping
|
||||||
|
from app.schemas.psa_tickets import (
|
||||||
|
PSAResourceSchema,
|
||||||
|
PSATicketCreatedSchema,
|
||||||
|
PSATicketStatusUpdateSchema,
|
||||||
|
)
|
||||||
|
from app.services.psa.registry import get_provider_for_account
|
||||||
|
from app.services.psa.types import TicketCreatePayload
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]:
|
||||||
|
"""Return set of external_member_id ints that are mapped to RF users."""
|
||||||
|
conn_result = await db.execute(
|
||||||
|
select(PsaConnection).where(PsaConnection.account_id == account_id)
|
||||||
|
)
|
||||||
|
conn = conn_result.scalar_one_or_none()
|
||||||
|
if not conn:
|
||||||
|
return set()
|
||||||
|
mappings = await db.execute(
|
||||||
|
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
|
||||||
|
)
|
||||||
|
return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_resources(
|
||||||
|
account_id: UUID, ticket_id: int, db: AsyncSession
|
||||||
|
) -> list[PSAResourceSchema]:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
mapped_ids = await _get_mapped_member_ids(account_id, db)
|
||||||
|
resources = await provider.list_resources(ticket_id)
|
||||||
|
return [
|
||||||
|
PSAResourceSchema(
|
||||||
|
member_id=r.member_id,
|
||||||
|
member_name=r.member_name,
|
||||||
|
member_identifier=r.member_identifier,
|
||||||
|
is_rf_user=r.member_id in mapped_ids,
|
||||||
|
)
|
||||||
|
for r in resources
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_resource(
|
||||||
|
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
|
||||||
|
) -> PSAResourceSchema:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
mapped_ids = await _get_mapped_member_ids(account_id, db)
|
||||||
|
resource = await provider.add_resource(ticket_id, member_id)
|
||||||
|
return PSAResourceSchema(
|
||||||
|
member_id=resource.member_id,
|
||||||
|
member_name=resource.member_name,
|
||||||
|
member_identifier=resource.member_identifier,
|
||||||
|
is_rf_user=resource.member_id in mapped_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_resource(
|
||||||
|
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
await provider.remove_resource(ticket_id, member_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession
|
||||||
|
) -> PSATicketStatusUpdateSchema:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
# get current status before updating
|
||||||
|
ticket = await provider.get_ticket(str(ticket_id))
|
||||||
|
previous_status = ticket.status_name or ""
|
||||||
|
await provider.update_ticket_status(str(ticket_id), status_id)
|
||||||
|
# get new status name from statuses list
|
||||||
|
statuses = await provider.get_ticket_statuses(ticket.board_id or 0)
|
||||||
|
new_status = next((s.name for s in statuses if s.id == status_id), str(status_id))
|
||||||
|
return PSATicketStatusUpdateSchema(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status=new_status,
|
||||||
|
new_status_id=status_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ticket(
|
||||||
|
account_id: UUID, payload: TicketCreatePayload, db: AsyncSession
|
||||||
|
) -> PSATicketCreatedSchema:
|
||||||
|
provider = await get_provider_for_account(account_id, db)
|
||||||
|
mapped_ids = await _get_mapped_member_ids(account_id, db)
|
||||||
|
result = await provider.create_ticket(payload)
|
||||||
|
return PSATicketCreatedSchema(
|
||||||
|
id=result.id,
|
||||||
|
summary=result.summary,
|
||||||
|
board_name=result.board_name,
|
||||||
|
status_name=result.status_name,
|
||||||
|
priority_name=result.priority_name,
|
||||||
|
company_name=result.company_name,
|
||||||
|
resources=[
|
||||||
|
PSAResourceSchema(
|
||||||
|
member_id=r.member_id,
|
||||||
|
member_name=r.member_name,
|
||||||
|
member_identifier=r.member_identifier,
|
||||||
|
is_rf_user=r.member_id in mapped_ids,
|
||||||
|
)
|
||||||
|
for r in result.resources
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -3,22 +3,41 @@
|
|||||||
Replaces assistant_chat_service for new chat sessions. Messages are stored
|
Replaces assistant_chat_service for new chat sessions. Messages are stored
|
||||||
in ai_sessions.conversation_messages JSONB. Reuses the same AI calling
|
in ai_sessions.conversation_messages JSONB. Reuses the same AI calling
|
||||||
infrastructure and system prompt from assistant_chat_service.
|
infrastructure and system prompt from assistant_chat_service.
|
||||||
|
|
||||||
|
## Markers parsed here
|
||||||
|
- `[QUESTIONS]` / `[ACTIONS]` — task-lane items shown to the engineer
|
||||||
|
- `[FORK]` — diagnostic forking, creates SessionBranch rows
|
||||||
|
- `[PROMOTE]` (Phase 2) — surfaces a fact to the What-we-know section.
|
||||||
|
Items in pending_task_lane carry stable UUIDs (assigned here) so PROMOTE
|
||||||
|
source_refs survive across turns even when the model re-emits the same
|
||||||
|
question/action.
|
||||||
|
- `[SUGGEST_FIX]` (Phase 3) — proposes a resolution path for the session.
|
||||||
|
Each new emission supersedes the previous active row (sets superseded_at)
|
||||||
|
so there's exactly one active fix at a time.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import uuid as _uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
from app.models.ai_session import AISession
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.script_template import ScriptTemplate
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
from app.services.assistant_chat_service import (
|
from app.services.assistant_chat_service import (
|
||||||
ASSISTANT_SYSTEM_PROMPT,
|
ASSISTANT_SYSTEM_PROMPT,
|
||||||
_call_ai,
|
_call_ai,
|
||||||
_auto_title,
|
_auto_title,
|
||||||
)
|
)
|
||||||
|
from app.services.fact_synthesis_service import FactSynthesisService
|
||||||
from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
|
from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -147,6 +166,378 @@ def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]]
|
|||||||
return cleaned, valid_questions
|
return cleaned, valid_questions
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_promote_marker(ai_content: str) -> tuple[str, list[dict[str, Any]] | None]:
|
||||||
|
"""Extract one or more [PROMOTE]...[/PROMOTE] JSON blocks from AI response.
|
||||||
|
|
||||||
|
Each block contains a JSON object describing a candidate fact:
|
||||||
|
{"source_type": "question"|"diagnostic_check"|"ai_synthesis",
|
||||||
|
"source_ref": "<task_lane_item_uuid>" | null,
|
||||||
|
"text": "<fact text>",
|
||||||
|
"summary": "<short provenance, optional>"}
|
||||||
|
|
||||||
|
Returns (cleaned_content, list_of_items_or_None). All matched blocks are
|
||||||
|
stripped from display text. Invalid items are dropped silently with a
|
||||||
|
warning — a malformed PROMOTE should never break the chat response.
|
||||||
|
|
||||||
|
Per FLOWPILOT-MIGRATION.md Section 8.1, the model emits text + summary
|
||||||
|
inline so no LLM round-trip is needed to persist the fact.
|
||||||
|
"""
|
||||||
|
blocks = list(re.finditer(r"\[PROMOTE\]\s*([\s\S]*?)\s*\[/PROMOTE\]", ai_content))
|
||||||
|
if not blocks:
|
||||||
|
return ai_content, None
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for m in blocks:
|
||||||
|
raw = m.group(1).strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
|
raw = re.sub(r"\s*```$", "", raw)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse [PROMOTE] block: %s", e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning("[PROMOTE] block must be a JSON object, got %s", type(data).__name__)
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_type = data.get("source_type")
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
summary = (data.get("summary") or "").strip() or None
|
||||||
|
source_ref_raw = data.get("source_ref")
|
||||||
|
|
||||||
|
if source_type not in ("question", "diagnostic_check", "ai_synthesis"):
|
||||||
|
# `user_note` is engineer-only, not an AI-emittable type.
|
||||||
|
logger.warning("Invalid [PROMOTE] source_type=%r, skipping", source_type)
|
||||||
|
continue
|
||||||
|
if not text:
|
||||||
|
logger.warning("[PROMOTE] block missing text, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_ref: UUID | None = None
|
||||||
|
if source_ref_raw:
|
||||||
|
try:
|
||||||
|
source_ref = UUID(str(source_ref_raw))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
logger.warning("[PROMOTE] source_ref %r is not a valid UUID, dropping ref", source_ref_raw)
|
||||||
|
source_ref = None
|
||||||
|
|
||||||
|
# `ai_synthesis` must NEVER carry a source_ref (no question/check item
|
||||||
|
# to point at) — surface mistakes from the model rather than tripping
|
||||||
|
# the FactSynthesisService validation later.
|
||||||
|
if source_type == "ai_synthesis":
|
||||||
|
source_ref = None
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"source_type": source_type,
|
||||||
|
"source_ref": source_ref,
|
||||||
|
"text": text,
|
||||||
|
"summary": summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Strip all PROMOTE blocks from display content — engineers see facts in
|
||||||
|
# the What-we-know panel, not as raw markers in the chat.
|
||||||
|
cleaned = re.sub(r"\[PROMOTE\]\s*[\s\S]*?\s*\[/PROMOTE\]", "", ai_content).strip()
|
||||||
|
|
||||||
|
return cleaned, items or None
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_stable_task_lane_ids(
|
||||||
|
prev_lane: dict[str, Any] | None,
|
||||||
|
questions: list[dict[str, Any]] | None,
|
||||||
|
actions: list[dict[str, Any]] | None,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Assign stable UUIDs to task-lane items, preserving them across turns.
|
||||||
|
|
||||||
|
The model often re-emits the same question/action across multiple turns
|
||||||
|
(it is told to keep `_(not yet completed)_` items alive). When the
|
||||||
|
question text matches a prior turn's, we keep the prior UUID so any
|
||||||
|
`session_facts.source_ref` pointing at it stays valid.
|
||||||
|
|
||||||
|
Match key:
|
||||||
|
- Questions: exact `text`
|
||||||
|
- Actions: exact `label`
|
||||||
|
|
||||||
|
Returns the questions/actions lists augmented with an `id` field.
|
||||||
|
"""
|
||||||
|
prev_questions = (prev_lane or {}).get("questions") or []
|
||||||
|
prev_actions = (prev_lane or {}).get("actions") or []
|
||||||
|
|
||||||
|
prev_q_ids: dict[str, str] = {
|
||||||
|
str(q.get("text") or "").strip(): str(q["id"])
|
||||||
|
for q in prev_questions
|
||||||
|
if isinstance(q, dict) and q.get("id") and q.get("text")
|
||||||
|
}
|
||||||
|
prev_a_ids: dict[str, str] = {
|
||||||
|
str(a.get("label") or "").strip(): str(a["id"])
|
||||||
|
for a in prev_actions
|
||||||
|
if isinstance(a, dict) and a.get("id") and a.get("label")
|
||||||
|
}
|
||||||
|
|
||||||
|
out_questions: list[dict[str, Any]] = []
|
||||||
|
for q in questions or []:
|
||||||
|
text = str(q.get("text") or "").strip()
|
||||||
|
existing = prev_q_ids.get(text) if text else None
|
||||||
|
out_questions.append({
|
||||||
|
**q,
|
||||||
|
"id": existing or str(_uuid.uuid4()),
|
||||||
|
})
|
||||||
|
|
||||||
|
out_actions: list[dict[str, Any]] = []
|
||||||
|
for a in actions or []:
|
||||||
|
label = str(a.get("label") or "").strip()
|
||||||
|
existing = prev_a_ids.get(label) if label else None
|
||||||
|
out_actions.append({
|
||||||
|
**a,
|
||||||
|
"id": existing or str(_uuid.uuid4()),
|
||||||
|
})
|
||||||
|
|
||||||
|
return out_questions, out_actions
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_suggest_fix_marker(
|
||||||
|
ai_content: str,
|
||||||
|
) -> tuple[str, dict[str, Any] | None]:
|
||||||
|
"""Extract a single [SUGGEST_FIX]...[/SUGGEST_FIX] JSON block from AI response.
|
||||||
|
|
||||||
|
The block contains:
|
||||||
|
{"title": "...", "description": "...", "confidence": 0..100,
|
||||||
|
"script_template_slug": "..." | null,
|
||||||
|
"ai_drafted_script": "..." | null,
|
||||||
|
"ai_drafted_parameters": {...} | null}
|
||||||
|
|
||||||
|
Per FLOWPILOT-MIGRATION.md Section 8.2. Only the LAST block in the response
|
||||||
|
is honored — if the model emits multiple, only its final view of the fix
|
||||||
|
matters; earlier ones in the same turn are stale even before persistence.
|
||||||
|
|
||||||
|
Returns (cleaned_content, fix_dict_or_None). Marker stripped from display.
|
||||||
|
"""
|
||||||
|
blocks = list(re.finditer(r"\[SUGGEST_FIX\]\s*([\s\S]*?)\s*\[/SUGGEST_FIX\]", ai_content))
|
||||||
|
if not blocks:
|
||||||
|
return ai_content, None
|
||||||
|
|
||||||
|
# Take the last block — most-recent intent wins within a single turn.
|
||||||
|
last = blocks[-1]
|
||||||
|
raw = last.group(1).strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
|
raw = re.sub(r"\s*```$", "", raw)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse [SUGGEST_FIX] block: %s", e)
|
||||||
|
return re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip(), None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip(), None
|
||||||
|
|
||||||
|
title = (data.get("title") or "").strip()
|
||||||
|
description = (data.get("description") or "").strip()
|
||||||
|
confidence = data.get("confidence")
|
||||||
|
if not title or not description or not isinstance(confidence, (int, float)):
|
||||||
|
logger.warning("[SUGGEST_FIX] missing required fields, dropping")
|
||||||
|
return re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip(), None
|
||||||
|
|
||||||
|
confidence_int = max(0, min(100, int(round(float(confidence)))))
|
||||||
|
|
||||||
|
parsed = {
|
||||||
|
"title": title[:200],
|
||||||
|
"description": description,
|
||||||
|
"confidence_pct": confidence_int,
|
||||||
|
"script_template_slug": (data.get("script_template_slug") or None),
|
||||||
|
"ai_drafted_script": (data.get("ai_drafted_script") or None),
|
||||||
|
"ai_drafted_parameters": data.get("ai_drafted_parameters") if isinstance(data.get("ai_drafted_parameters"), dict) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = re.sub(r"\[SUGGEST_FIX\]\s*[\s\S]*?\s*\[/SUGGEST_FIX\]", "", ai_content).strip()
|
||||||
|
return cleaned, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fix_outcome_marker(
|
||||||
|
ai_content: str,
|
||||||
|
) -> tuple[str, dict[str, Any] | None]:
|
||||||
|
"""Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block.
|
||||||
|
|
||||||
|
Block shape:
|
||||||
|
{"fix_id": "<uuid>", "outcome": "success"|"failure"|"partial",
|
||||||
|
"reason": "<one-line>"}
|
||||||
|
|
||||||
|
Emitted by the AI when the engineer clearly indicates in chat that a
|
||||||
|
prior suggested fix worked, didn't work, or was partially applied.
|
||||||
|
The marker PROPOSES an outcome — the engineer confirms via the UI.
|
||||||
|
Only the last block in a response is honored.
|
||||||
|
"""
|
||||||
|
blocks = list(re.finditer(
|
||||||
|
r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content,
|
||||||
|
))
|
||||||
|
if not blocks:
|
||||||
|
return ai_content, None
|
||||||
|
|
||||||
|
last = blocks[-1]
|
||||||
|
raw = last.group(1).strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
|
raw = re.sub(r"\s*```$", "", raw)
|
||||||
|
|
||||||
|
cleaned = re.sub(
|
||||||
|
r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content,
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e)
|
||||||
|
return cleaned, None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return cleaned, None
|
||||||
|
|
||||||
|
fix_id = str(data.get("fix_id") or "").strip()
|
||||||
|
outcome = str(data.get("outcome") or "").strip().lower()
|
||||||
|
reason = str(data.get("reason") or "").strip()
|
||||||
|
|
||||||
|
if not fix_id or outcome not in {"success", "failure", "partial"}:
|
||||||
|
logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping")
|
||||||
|
return cleaned, None
|
||||||
|
|
||||||
|
return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_suggested_fix(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
session: AISession,
|
||||||
|
fix: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Supersede the prior active fix and insert the new one. Bumps state_version.
|
||||||
|
|
||||||
|
A session has at most one active suggested fix (`superseded_at IS NULL`).
|
||||||
|
Emitting [SUGGEST_FIX] is the only way to introduce a new one; the
|
||||||
|
engineer's user_decision is recorded via the decision endpoint.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Mark any prior active rows for this session as superseded.
|
||||||
|
await db.execute(
|
||||||
|
update(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.session_id == session.id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(superseded_at=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve script_template_slug → script_template_id if provided.
|
||||||
|
script_template_id = None
|
||||||
|
slug = fix.get("script_template_slug")
|
||||||
|
if slug:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ScriptTemplate).where(ScriptTemplate.slug == slug)
|
||||||
|
)
|
||||||
|
tpl = result.scalar_one_or_none()
|
||||||
|
if tpl is not None:
|
||||||
|
script_template_id = tpl.id
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"SUGGEST_FIX referenced unknown script_template_slug=%r — "
|
||||||
|
"treating as no template match", slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title=fix["title"],
|
||||||
|
description=fix["description"],
|
||||||
|
confidence_pct=fix["confidence_pct"],
|
||||||
|
script_template_id=script_template_id,
|
||||||
|
ai_drafted_script=fix.get("ai_drafted_script"),
|
||||||
|
ai_drafted_parameters=fix.get("ai_drafted_parameters"),
|
||||||
|
)
|
||||||
|
db.add(new_fix)
|
||||||
|
|
||||||
|
# Bump preview-cache version atomically with the supersession+insert.
|
||||||
|
await db.execute(
|
||||||
|
update(AISession)
|
||||||
|
.where(AISession.id == session.id)
|
||||||
|
.values(state_version=AISession.state_version + 1)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_ai_outcome_proposal(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
session: AISession,
|
||||||
|
proposal: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Persist the AI's proposed outcome on the active fix.
|
||||||
|
|
||||||
|
Writes to session_suggested_fixes.ai_outcome_proposal. Frontend polls
|
||||||
|
the active fix and renders the AI-confirming banner state when this is
|
||||||
|
non-null. Does NOT mutate the fix's status — the engineer's confirmation
|
||||||
|
click via PATCH /outcome is what changes the status.
|
||||||
|
|
||||||
|
Drops silently when the fix_id isn't a valid UUID or doesn't belong to
|
||||||
|
this session.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fix_uuid = UUID(proposal["fix_id"])
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
logger.warning("[FIX_OUTCOME] invalid fix_id, dropping")
|
||||||
|
return
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(SessionSuggestedFix)
|
||||||
|
.where(
|
||||||
|
SessionSuggestedFix.id == fix_uuid,
|
||||||
|
SessionSuggestedFix.session_id == session.id,
|
||||||
|
)
|
||||||
|
.values(ai_outcome_proposal=proposal)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_promote_items(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
session: AISession,
|
||||||
|
user_id: UUID,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Persist parsed [PROMOTE] items as session_facts. Failures are logged.
|
||||||
|
|
||||||
|
A malformed PROMOTE must never break the chat response — the engineer
|
||||||
|
still gets the AI's analysis; the missing fact can be added manually.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
service = FactSynthesisService(db)
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
await service.create_fact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
user_id=user_id,
|
||||||
|
source_type=item["source_type"],
|
||||||
|
text=item["text"],
|
||||||
|
summary=item["summary"],
|
||||||
|
source_ref=item["source_ref"],
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Validation failure (e.g. empty text after strip, or
|
||||||
|
# source_ref-on-ai_synthesis race). Log and continue — losing
|
||||||
|
# one fact is better than aborting the whole chat turn.
|
||||||
|
logger.warning(
|
||||||
|
"Skipping invalid PROMOTE item for session %s: %r",
|
||||||
|
session.id, item, exc_info=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to persist PROMOTE item for session %s", session.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def create_chat_session(
|
async def create_chat_session(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
account_id: UUID,
|
account_id: UUID,
|
||||||
@@ -251,10 +642,14 @@ async def send_chat_message(
|
|||||||
if session.status == "paused":
|
if session.status == "paused":
|
||||||
session.status = "active"
|
session.status = "active"
|
||||||
|
|
||||||
# Check for fork, actions, and questions markers in branch response too
|
# Check for fork, actions, questions, promote, and suggest_fix markers
|
||||||
|
# in branch response too
|
||||||
branch_display, branch_fork_data = _parse_fork_marker(ai_content)
|
branch_display, branch_fork_data = _parse_fork_marker(ai_content)
|
||||||
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
||||||
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
|
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
|
||||||
|
branch_display, branch_promote_items = _parse_promote_marker(branch_display)
|
||||||
|
branch_display, branch_suggest_fix = _parse_suggest_fix_marker(branch_display)
|
||||||
|
branch_display, branch_outcome_proposal = _parse_fix_outcome_marker(branch_display)
|
||||||
if branch_display != ai_content:
|
if branch_display != ai_content:
|
||||||
# Store stripped content in branch history
|
# Store stripped content in branch history
|
||||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
msgs[-1] = {"role": "assistant", "content": branch_display}
|
||||||
@@ -288,15 +683,42 @@ async def send_chat_message(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to create fork within branch for session %s", session.id)
|
logger.exception("Failed to create fork within branch for session %s", session.id)
|
||||||
|
|
||||||
# Persist task lane state on session
|
# Persist task lane state on session — assign stable UUIDs so any
|
||||||
|
# PROMOTE marker emitted later can reference the same items.
|
||||||
if branch_questions_data or branch_actions_data:
|
if branch_questions_data or branch_actions_data:
|
||||||
|
stable_qs, stable_as = _assign_stable_task_lane_ids(
|
||||||
|
session.pending_task_lane,
|
||||||
|
branch_questions_data,
|
||||||
|
branch_actions_data,
|
||||||
|
)
|
||||||
session.pending_task_lane = {
|
session.pending_task_lane = {
|
||||||
"questions": branch_questions_data or [],
|
"questions": stable_qs,
|
||||||
"actions": branch_actions_data or [],
|
"actions": stable_as,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
session.pending_task_lane = None
|
session.pending_task_lane = None
|
||||||
|
|
||||||
|
# Persist any PROMOTE items emitted in this turn. Done AFTER the
|
||||||
|
# task-lane write so source_refs to brand-new items would still
|
||||||
|
# land on persisted UUIDs (the model can also reference IDs from
|
||||||
|
# the previous turn, which were already persisted).
|
||||||
|
if branch_promote_items:
|
||||||
|
await _persist_promote_items(
|
||||||
|
db=db, session=session, user_id=user_id, items=branch_promote_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist a [SUGGEST_FIX] if the branch turn included one.
|
||||||
|
if branch_suggest_fix:
|
||||||
|
await _persist_suggested_fix(
|
||||||
|
db=db, session=session, fix=branch_suggest_fix,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist a [FIX_OUTCOME] proposal if the branch turn included one.
|
||||||
|
if branch_outcome_proposal is not None:
|
||||||
|
await _record_ai_outcome_proposal(
|
||||||
|
db=db, session=session, proposal=branch_outcome_proposal,
|
||||||
|
)
|
||||||
|
|
||||||
suggested_flows = extract_suggested_flows(
|
suggested_flows = extract_suggested_flows(
|
||||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
||||||
)
|
)
|
||||||
@@ -343,9 +765,22 @@ async def send_chat_message(
|
|||||||
# Check for questions marker in AI response
|
# Check for questions marker in AI response
|
||||||
display_content, questions_data = _parse_questions_marker(display_content)
|
display_content, questions_data = _parse_questions_marker(display_content)
|
||||||
|
|
||||||
|
# Check for promote markers — facts the AI is surfacing to What we know.
|
||||||
|
display_content, promote_items = _parse_promote_marker(display_content)
|
||||||
|
|
||||||
|
# Check for a [SUGGEST_FIX] marker — supersedes the prior active fix.
|
||||||
|
display_content, suggest_fix_data = _parse_suggest_fix_marker(display_content)
|
||||||
|
|
||||||
|
# Check for a [FIX_OUTCOME] proposal — AI confirms a prior fix's outcome.
|
||||||
|
display_content, outcome_proposal = _parse_fix_outcome_marker(display_content)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d",
|
"Marker parsing results — actions: %s, questions: %s, fork: %s, "
|
||||||
|
"promote: %d, suggest_fix: %s, outcome_proposal: %s, "
|
||||||
|
"raw_length: %d, display_length: %d",
|
||||||
bool(actions_data), bool(questions_data), bool(fork_data),
|
bool(actions_data), bool(questions_data), bool(fork_data),
|
||||||
|
len(promote_items or []), bool(suggest_fix_data),
|
||||||
|
bool(outcome_proposal),
|
||||||
len(ai_content), len(display_content),
|
len(ai_content), len(display_content),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -410,15 +845,36 @@ async def send_chat_message(
|
|||||||
logger.exception("Failed to create fork for session %s", session_id)
|
logger.exception("Failed to create fork for session %s", session_id)
|
||||||
# Fork failed but chat message still sent — don't break the response
|
# Fork failed but chat message still sent — don't break the response
|
||||||
|
|
||||||
# Persist task lane state on session
|
# Persist task lane state on session — assign stable UUIDs so any PROMOTE
|
||||||
|
# marker (this turn or a later one) can reference the same items.
|
||||||
if questions_data or actions_data:
|
if questions_data or actions_data:
|
||||||
|
stable_qs, stable_as = _assign_stable_task_lane_ids(
|
||||||
|
session.pending_task_lane, questions_data, actions_data,
|
||||||
|
)
|
||||||
session.pending_task_lane = {
|
session.pending_task_lane = {
|
||||||
"questions": questions_data or [],
|
"questions": stable_qs,
|
||||||
"actions": actions_data or [],
|
"actions": stable_as,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
session.pending_task_lane = None
|
session.pending_task_lane = None
|
||||||
|
|
||||||
|
# Persist any PROMOTE items emitted in this turn. Done after task-lane
|
||||||
|
# assignment so source_refs the model invented this turn already exist.
|
||||||
|
if promote_items:
|
||||||
|
await _persist_promote_items(
|
||||||
|
db=db, session=session, user_id=user_id, items=promote_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist a [SUGGEST_FIX] if this turn included one — supersedes prior fix.
|
||||||
|
if suggest_fix_data:
|
||||||
|
await _persist_suggested_fix(db=db, session=session, fix=suggest_fix_data)
|
||||||
|
|
||||||
|
# Persist a [FIX_OUTCOME] proposal if this turn included one.
|
||||||
|
if outcome_proposal is not None:
|
||||||
|
await _record_ai_outcome_proposal(
|
||||||
|
db=db, session=session, proposal=outcome_proposal,
|
||||||
|
)
|
||||||
|
|
||||||
suggested_flows = extract_suggested_flows(rag_results)
|
suggested_flows = extract_suggested_flows(rag_results)
|
||||||
|
|
||||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ markers =
|
|||||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||||
integration: marks tests as integration tests
|
integration: marks tests as integration tests
|
||||||
unit: marks tests as unit tests
|
unit: marks tests as unit tests
|
||||||
|
rls: opt-in RLS migration and policy tests (run with RUN_RLS_TESTS=1)
|
||||||
|
|
||||||
# Ignore paths
|
# Ignore paths
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Include production dependencies
|
# Include production dependencies
|
||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
|
|
||||||
# Testing
|
# Testing — pytest-asyncio 0.24+ requires pytest>=8.2
|
||||||
pytest==7.4.3
|
pytest==8.4.2
|
||||||
pytest-asyncio==0.23.0
|
pytest-asyncio==0.24.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pytest-cov==4.1.0
|
pytest-cov==5.0.0
|
||||||
|
|
||||||
# Code quality
|
# Code quality
|
||||||
black==24.1.1
|
black==24.1.1
|
||||||
|
|||||||
375
backend/scripts/seed_phase9_qa_fixtures.py
Normal file
375
backend/scripts/seed_phase9_qa_fixtures.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed Phase 9 QA fixtures: 4 ai_sessions + matching suggested_fixes that
|
||||||
|
exercise the five Phase 9 components which gate on a backend-emitted
|
||||||
|
`SUGGEST_FIX` action and don't fire reliably in normal local sessions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd backend
|
||||||
|
python -m scripts.seed_phase9_qa_fixtures
|
||||||
|
python -m scripts.seed_phase9_qa_fixtures --reset # delete & recreate
|
||||||
|
|
||||||
|
Targets the super-admin from `seed_test_users.py`
|
||||||
|
(admin@resolutionflow.example.com) and their account. UUIDs are
|
||||||
|
deterministic (UUID5 over a fixed namespace) so re-runs are idempotent
|
||||||
|
without --reset.
|
||||||
|
|
||||||
|
Sessions created:
|
||||||
|
|
||||||
|
| # | Title | Phase 9 component reached when… |
|
||||||
|
|---|---------------------------------|-------------------------------------------------------|
|
||||||
|
| A | Phase 9 QA — no-template path | ChatTabStrip + ScriptBuilderTab + ProposalBanner |
|
||||||
|
| B | Phase 9 QA — drafted-script | InlineNoTemplateDialog + ProposalBanner |
|
||||||
|
| C | Phase 9 QA — template match | TemplateMatchPanel + ProposalBanner |
|
||||||
|
| D | Phase 9 QA — verify state | EscalateInterceptDialog (with new "partial" choice) |
|
||||||
|
|
||||||
|
Run /qa, then in the browser go to /pilot, click each session in the
|
||||||
|
sidebar, and exercise its Phase 9 surface. The session URLs are printed
|
||||||
|
at the end.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_EMAIL = "admin@resolutionflow.example.com"
|
||||||
|
|
||||||
|
# Deterministic UUIDs so re-running the seeder updates rather than duplicates.
|
||||||
|
NS = uuid.UUID("00000000-0000-0000-0000-000000000901")
|
||||||
|
SESSION_A = uuid.uuid5(NS, "session-A-no-template")
|
||||||
|
SESSION_B = uuid.uuid5(NS, "session-B-drafted-script")
|
||||||
|
SESSION_C = uuid.uuid5(NS, "session-C-template-match")
|
||||||
|
SESSION_D = uuid.uuid5(NS, "session-D-verify-state")
|
||||||
|
FIX_A = uuid.uuid5(NS, "fix-A")
|
||||||
|
FIX_B = uuid.uuid5(NS, "fix-B")
|
||||||
|
FIX_C = uuid.uuid5(NS, "fix-C")
|
||||||
|
FIX_D = uuid.uuid5(NS, "fix-D")
|
||||||
|
CATEGORY_QA = uuid.uuid5(NS, "category-qa-fixtures")
|
||||||
|
TEMPLATE_QA = uuid.uuid5(NS, "template-qa-fixtures")
|
||||||
|
|
||||||
|
DRAFTED_SCRIPT = """\
|
||||||
|
# Phase 9 QA fixture — AI-drafted PowerShell to flush DNS and
|
||||||
|
# restart the FortiClient service. Not for production use.
|
||||||
|
ipconfig /flushdns
|
||||||
|
Restart-Service -Name "FortiSslvpnDaemon" -Force
|
||||||
|
Get-Service -Name "FortiSslvpnDaemon" | Format-Table -AutoSize
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEMPLATE_BODY = """\
|
||||||
|
# Phase 9 QA fixture — canned template that the AI matches against.
|
||||||
|
param([string]$ServiceName = "FortiSslvpnDaemon")
|
||||||
|
Restart-Service -Name $ServiceName -Force
|
||||||
|
Get-Service -Name $ServiceName | Select-Object Status, Name
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def main(reset: bool = False) -> None:
|
||||||
|
db_url = (
|
||||||
|
settings.ADMIN_DATABASE_URL
|
||||||
|
if hasattr(settings, "ADMIN_DATABASE_URL") and settings.ADMIN_DATABASE_URL
|
||||||
|
else settings.DATABASE_URL
|
||||||
|
)
|
||||||
|
engine = create_async_engine(db_url, echo=False)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# ─── Locate the admin user + account ───────────────────────────
|
||||||
|
row = (
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, account_id FROM users WHERE email = :email LIMIT 1"
|
||||||
|
),
|
||||||
|
{"email": ADMIN_EMAIL},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
print(
|
||||||
|
f"ERROR: user {ADMIN_EMAIL!r} not found. Run "
|
||||||
|
"`python -m scripts.seed_test_users` first.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
user_id, account_id = row
|
||||||
|
|
||||||
|
if reset:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM session_suggested_fixes WHERE id = ANY(:ids)"
|
||||||
|
),
|
||||||
|
{"ids": [FIX_A, FIX_B, FIX_C, FIX_D]},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("DELETE FROM ai_sessions WHERE id = ANY(:ids)"),
|
||||||
|
{"ids": [SESSION_A, SESSION_B, SESSION_C, SESSION_D]},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("DELETE FROM script_templates WHERE id = :id"),
|
||||||
|
{"id": TEMPLATE_QA},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text("DELETE FROM script_categories WHERE id = :id"),
|
||||||
|
{"id": CATEGORY_QA},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Script category + template (for Session C) ────────────────
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO script_categories (id, name, slug, sort_order, is_active, created_at, updated_at)
|
||||||
|
VALUES (:id, 'QA Fixtures', 'qa-fixtures', 999, true, :now, :now)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"id": CATEGORY_QA, "now": now},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO script_templates (
|
||||||
|
id, category_id, account_id, created_by, name, slug,
|
||||||
|
description, script_body, language, parameters_schema,
|
||||||
|
default_values, validation_rules, tags, complexity,
|
||||||
|
requires_elevation, requires_modules, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:id, :cat_id, :acct_id, :user_id,
|
||||||
|
'QA Fixture: Restart Forti Service',
|
||||||
|
'qa-fixture-restart-forti-service',
|
||||||
|
'Phase 9 QA fixture template for TemplateMatchPanel testing.',
|
||||||
|
:body, 'powershell',
|
||||||
|
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '[]'::jsonb,
|
||||||
|
'beginner', false, '[]'::jsonb,
|
||||||
|
:now, :now
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": TEMPLATE_QA,
|
||||||
|
"cat_id": CATEGORY_QA,
|
||||||
|
"acct_id": account_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"body": TEMPLATE_BODY,
|
||||||
|
"now": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── 4 sessions ────────────────────────────────────────────────
|
||||||
|
# `canAct` in the chat header gates Resolve/Escalate on
|
||||||
|
# `messages.length >= 2`, so each fixture seeds two synthetic
|
||||||
|
# conversation messages — enough to enable the buttons that drive
|
||||||
|
# the Phase 9 surfaces.
|
||||||
|
seed_messages = (
|
||||||
|
'['
|
||||||
|
'{"role":"user","content":"QA fixture: see seed_phase9_qa_fixtures.py"},'
|
||||||
|
'{"role":"assistant","content":"This session is a Phase 9 QA fixture. The suggested fix below is pre-seeded — drive it from the UI."}'
|
||||||
|
']'
|
||||||
|
)
|
||||||
|
sessions = [
|
||||||
|
(SESSION_A, "Phase 9 QA — no-template path"),
|
||||||
|
(SESSION_B, "Phase 9 QA — drafted-script path"),
|
||||||
|
(SESSION_C, "Phase 9 QA — template-match path"),
|
||||||
|
(SESSION_D, "Phase 9 QA — verify state (Escalate intercept)"),
|
||||||
|
]
|
||||||
|
for sid, title in sessions:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO ai_sessions (
|
||||||
|
id, user_id, account_id, session_type, title,
|
||||||
|
intake_type, intake_content, status, confidence_tier,
|
||||||
|
confidence_score, conversation_messages,
|
||||||
|
total_input_tokens, total_output_tokens, step_count,
|
||||||
|
is_branching, state_version,
|
||||||
|
handoff_count, total_active_seconds, total_parked_seconds,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:id, :user_id, :acct_id, 'chat', :title,
|
||||||
|
'free_text', '{"text": "QA fixture session"}'::jsonb,
|
||||||
|
'active', 'discovery',
|
||||||
|
0.0, (:msgs)::jsonb,
|
||||||
|
0, 0, 0,
|
||||||
|
false, 0,
|
||||||
|
0, 0, 0,
|
||||||
|
:now, :now
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
conversation_messages = EXCLUDED.conversation_messages,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": sid,
|
||||||
|
"user_id": user_id,
|
||||||
|
"acct_id": account_id,
|
||||||
|
"title": title,
|
||||||
|
"msgs": seed_messages,
|
||||||
|
"now": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── 4 suggested fixes ─────────────────────────────────────────
|
||||||
|
# Fix A — no template, no draft → ChatTabStrip + ScriptBuilderTab
|
||||||
|
await _upsert_fix(
|
||||||
|
conn, fix_id=FIX_A, session_id=SESSION_A, account_id=account_id,
|
||||||
|
title="Restart the FortiClient daemon and flush DNS",
|
||||||
|
description=(
|
||||||
|
"Error -8 on FortiClient SSL VPN typically clears after a "
|
||||||
|
"service restart on the endpoint. No matching template; "
|
||||||
|
"no AI draft yet — engineer should choose Build Template "
|
||||||
|
"or One-Off in the Script Builder tab."
|
||||||
|
),
|
||||||
|
confidence_pct=72,
|
||||||
|
script_template_id=None,
|
||||||
|
ai_drafted_script=None,
|
||||||
|
status="proposed",
|
||||||
|
applied_at=None,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix B — drafted script, no template → InlineNoTemplateDialog
|
||||||
|
await _upsert_fix(
|
||||||
|
conn, fix_id=FIX_B, session_id=SESSION_B, account_id=account_id,
|
||||||
|
title="Run AI-drafted PowerShell to recover SSL VPN",
|
||||||
|
description=(
|
||||||
|
"AI drafted a session-specific script because no library "
|
||||||
|
"template matched. Inline dialog should offer Save-as-template, "
|
||||||
|
"Run-once, or Discard."
|
||||||
|
),
|
||||||
|
confidence_pct=68,
|
||||||
|
script_template_id=None,
|
||||||
|
ai_drafted_script=DRAFTED_SCRIPT,
|
||||||
|
status="proposed",
|
||||||
|
applied_at=None,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix C — template match → TemplateMatchPanel
|
||||||
|
await _upsert_fix(
|
||||||
|
conn, fix_id=FIX_C, session_id=SESSION_C, account_id=account_id,
|
||||||
|
title="Match: QA Fixture Restart Forti Service",
|
||||||
|
description=(
|
||||||
|
"AI matched an existing library template. The match panel "
|
||||||
|
"should render with the parameterization preview and an "
|
||||||
|
"explicit 'I ran this' action."
|
||||||
|
),
|
||||||
|
confidence_pct=88,
|
||||||
|
script_template_id=TEMPLATE_QA,
|
||||||
|
ai_drafted_script=None,
|
||||||
|
status="proposed",
|
||||||
|
applied_at=None,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix D — applied_at set, status='proposed' → verify state.
|
||||||
|
# Hitting Escalate from this state opens EscalateInterceptDialog.
|
||||||
|
await _upsert_fix(
|
||||||
|
conn, fix_id=FIX_D, session_id=SESSION_D, account_id=account_id,
|
||||||
|
title="Verifying: post-apply tunnel reconnect",
|
||||||
|
description=(
|
||||||
|
"Engineer marked the fix as Applied; we're now in the "
|
||||||
|
"verify window. Clicking Escalate from here should open "
|
||||||
|
"the EscalateInterceptDialog with the four outcome choices "
|
||||||
|
"(worked / didn't / partial / never-applied)."
|
||||||
|
),
|
||||||
|
confidence_pct=80,
|
||||||
|
script_template_id=None,
|
||||||
|
ai_drafted_script=DRAFTED_SCRIPT,
|
||||||
|
status="proposed",
|
||||||
|
applied_at=now - timedelta(minutes=2),
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 64)
|
||||||
|
print(" Phase 9 QA fixtures ready.")
|
||||||
|
print("=" * 64)
|
||||||
|
print()
|
||||||
|
print(f" Sign in as : {ADMIN_EMAIL}")
|
||||||
|
print(f" Then visit : http://docker-01:5173/pilot")
|
||||||
|
print(f" Pick from the History sidebar:")
|
||||||
|
print(f" A. Phase 9 QA — no-template path (ChatTabStrip + ScriptBuilderTab)")
|
||||||
|
print(f" B. Phase 9 QA — drafted-script path (InlineNoTemplateDialog)")
|
||||||
|
print(f" C. Phase 9 QA — template-match path (TemplateMatchPanel)")
|
||||||
|
print(f" D. Phase 9 QA — verify state (EscalateInterceptDialog)")
|
||||||
|
print()
|
||||||
|
print(f" Re-run with --reset to wipe and recreate.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_fix(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
fix_id: uuid.UUID,
|
||||||
|
session_id: uuid.UUID,
|
||||||
|
account_id: uuid.UUID,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
confidence_pct: int,
|
||||||
|
script_template_id: uuid.UUID | None,
|
||||||
|
ai_drafted_script: str | None,
|
||||||
|
status: str,
|
||||||
|
applied_at: datetime | None,
|
||||||
|
now: datetime,
|
||||||
|
) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO session_suggested_fixes (
|
||||||
|
id, session_id, account_id, title, description,
|
||||||
|
confidence_pct, script_template_id, ai_drafted_script,
|
||||||
|
status, applied_at, created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:id, :sid, :acct, :title, :desc,
|
||||||
|
:conf, :tmpl, :draft,
|
||||||
|
:status, :applied, :now
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
confidence_pct = EXCLUDED.confidence_pct,
|
||||||
|
script_template_id = EXCLUDED.script_template_id,
|
||||||
|
ai_drafted_script = EXCLUDED.ai_drafted_script,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
applied_at = EXCLUDED.applied_at,
|
||||||
|
superseded_at = NULL
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": fix_id,
|
||||||
|
"sid": session_id,
|
||||||
|
"acct": account_id,
|
||||||
|
"title": title,
|
||||||
|
"desc": description,
|
||||||
|
"conf": confidence_pct,
|
||||||
|
"tmpl": script_template_id,
|
||||||
|
"draft": ai_drafted_script,
|
||||||
|
"status": status,
|
||||||
|
"applied": applied_at,
|
||||||
|
"now": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Seed Phase 9 QA fixtures.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete and recreate the fixtures.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(reset=args.reset))
|
||||||
@@ -161,8 +161,8 @@ async def main() -> None:
|
|||||||
if cfg["plan"] is not None:
|
if cfg["plan"] is not None:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
|
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
|
||||||
VALUES (:id, :aid, :plan, 'active', :now, :now)
|
VALUES (:id, :aid, :plan, 'active', false, :now, :now)
|
||||||
"""),
|
"""),
|
||||||
{"id": uuid.uuid4(), "aid": account_id, "plan": cfg["plan"], "now": now},
|
{"id": uuid.uuid4(), "aid": account_id, "plan": cfg["plan"], "now": now},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ Pytest configuration and fixtures for integration tests.
|
|||||||
Provides test database setup, client fixtures, and authentication helpers.
|
Provides test database setup, client fixtures, and authentication helpers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import os
|
||||||
from typing import AsyncGenerator, Generator
|
from typing import AsyncGenerator
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
@@ -14,30 +14,63 @@ from sqlalchemy.pool import NullPool
|
|||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.core.database import Base, get_db
|
from app.core.database import Base, get_db
|
||||||
|
from app.core.admin_database import get_admin_db
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
# Import every model module so all tables are registered with Base.metadata
|
||||||
|
# before the test_db fixture calls create_all. app.main imports models lazily
|
||||||
|
# (inside scheduler functions and route modules), which is fine at runtime
|
||||||
|
# but leaves the metadata incomplete at fixture-setup time — surfacing as
|
||||||
|
# "relation X does not exist" errors for any model whose route/scheduler
|
||||||
|
# hasn't been loaded yet. The `from app import models` form avoids
|
||||||
|
# shadowing the `app` FastAPI instance imported just above.
|
||||||
|
from app import models as _models # noqa: F401
|
||||||
|
|
||||||
# Disable invite code requirement for tests
|
# Disable invite code requirement for tests
|
||||||
settings.REQUIRE_INVITE_CODE = False
|
settings.REQUIRE_INVITE_CODE = False
|
||||||
|
|
||||||
# Test database URL (separate from production)
|
# Test database URL — NEVER reuse DATABASE_URL. The test_db fixture does
|
||||||
# Use DATABASE_TEST_URL env var if set (e.g. inside Docker where host is 'db'),
|
# `DROP SCHEMA public CASCADE` on every test; if DATABASE_URL (which normally
|
||||||
# otherwise fall back to localhost for local development.
|
# points at the dev/prod DB) leaked into this value, running `pytest tests/`
|
||||||
import os
|
# would silently nuke the dev database. Only DATABASE_TEST_URL is honored,
|
||||||
|
# and the safety assertion below refuses to run against a DB whose name
|
||||||
|
# doesn't contain "test".
|
||||||
TEST_DATABASE_URL = os.environ.get(
|
TEST_DATABASE_URL = os.environ.get(
|
||||||
"DATABASE_URL",
|
|
||||||
os.environ.get(
|
|
||||||
"DATABASE_TEST_URL",
|
"DATABASE_TEST_URL",
|
||||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test",
|
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Belt-and-suspenders: refuse to run tests against a DB whose name doesn't
|
||||||
|
# contain "test". Parses the last path segment of the URL (everything after
|
||||||
|
# the final '/', with query string stripped) so credentials / hosts that
|
||||||
|
# happen to contain "test" can't bypass the check.
|
||||||
|
_test_db_name = TEST_DATABASE_URL.rsplit("/", 1)[-1].split("?", 1)[0].lower()
|
||||||
|
assert "test" in _test_db_name, (
|
||||||
|
f"Refusing to run tests against database {_test_db_name!r} — "
|
||||||
|
f"the DB name must contain 'test'. Set DATABASE_TEST_URL to a dedicated "
|
||||||
|
f"test database (e.g. resolutionflow_test)."
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
_RUN_RLS_TESTS = os.environ.get("RUN_RLS_TESTS") == "1"
|
||||||
def event_loop() -> Generator:
|
_RLS_ISOLATION_FILE = "test_rls_isolation.py"
|
||||||
"""Create an instance of the default event loop for each test case."""
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
def pytest_collection_modifyitems(config, items):
|
||||||
loop.close()
|
"""Keep migration-managed RLS checks out of the default create_all suite."""
|
||||||
|
if _RUN_RLS_TESTS:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = []
|
||||||
|
deselected = []
|
||||||
|
for item in items:
|
||||||
|
item_path = getattr(item, "path", None) or getattr(item, "fspath", None)
|
||||||
|
if item_path and str(item_path).endswith(_RLS_ISOLATION_FILE):
|
||||||
|
deselected.append(item)
|
||||||
|
else:
|
||||||
|
selected.append(item)
|
||||||
|
|
||||||
|
if deselected:
|
||||||
|
config.hook.pytest_deselected(items=deselected)
|
||||||
|
items[:] = selected
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -131,6 +164,11 @@ async def client(test_db: AsyncSession):
|
|||||||
yield test_db
|
yield test_db
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_get_db
|
app.dependency_overrides[get_db] = override_get_db
|
||||||
|
# Endpoints that use get_admin_db (register, admin routes, service accounts)
|
||||||
|
# must also hit the test DB; otherwise they leak into the real admin DB.
|
||||||
|
# RLS is not enabled in the test schema (create_all, not alembic), so sharing
|
||||||
|
# the same session is safe.
|
||||||
|
app.dependency_overrides[get_admin_db] = override_get_db
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
|||||||
536
backend/tests/test_fix_outcome_endpoint.py
Normal file
536
backend/tests/test_fix_outcome_endpoint.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/outcome.
|
||||||
|
|
||||||
|
Fixture style follows test_session_suggested_fixes_api.py:
|
||||||
|
client, test_user, auth_headers, test_db
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_preview_cache():
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
yield
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
# ── shared helper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _make_session_with_fix(test_db, user) -> tuple[str, str]:
|
||||||
|
"""Create an AISession + active proposed SessionSuggestedFix.
|
||||||
|
|
||||||
|
Returns (session_id_str, fix_id_str).
|
||||||
|
"""
|
||||||
|
session = AISession(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "outcome test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="Reset credential cache",
|
||||||
|
description="Clear stale credentials from the domain cache.",
|
||||||
|
confidence_pct=82,
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(fix)
|
||||||
|
|
||||||
|
return str(session.id), str(fix.id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_outcome_marks_success(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "applied_success"
|
||||||
|
assert body["verified_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_outcome_partial_requires_notes(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_partial"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "notes" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_to_success_allowed(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r1 = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
|
||||||
|
r2 = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["status"] == "applied_success"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_terminal_outcome_is_locked(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r1 = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_failed", "notes": "no change"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
|
||||||
|
r2 = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_partial_notes_can_be_updated(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""partial→partial with new notes updates the stored notes."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r1 = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["partial_notes"] == "ran cred clear only"
|
||||||
|
|
||||||
|
r2 = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "applied_partial", "notes": "also finished the rebuild; not verified yet"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["partial_notes"] == "also finished the rebuild; not verified yet"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismissed_sets_no_timestamps(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""dismissed outcome does not stamp applied_at or verified_at."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "dismissed"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "dismissed"
|
||||||
|
assert body["applied_at"] is None
|
||||||
|
assert body["verified_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_applied_at_auto_stamped_on_first_outcome(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""If applied_at is null when the engineer sets outcome, server stamps it."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["applied_at"] is not None
|
||||||
|
assert body["verified_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_outcome_stores_notes_as_failure_reason(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""applied_failed stores notes under failure_reason (not partial_notes)."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "applied_failed", "notes": "user reports no change"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["failure_reason"] == "user reports no change"
|
||||||
|
assert body["partial_notes"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── state_version bump ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outcome_patch_bumps_state_version(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""PATCH /outcome must increment ai_sessions.state_version (like record_decision)."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
# Capture the initial state_version from DB.
|
||||||
|
from uuid import UUID
|
||||||
|
result = await test_db.execute(
|
||||||
|
select(AISession).where(AISession.id == UUID(session_id))
|
||||||
|
)
|
||||||
|
session_obj = result.scalar_one()
|
||||||
|
initial_version = session_obj.state_version
|
||||||
|
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
await test_db.refresh(session_obj)
|
||||||
|
assert session_obj.state_version == initial_version + 1, (
|
||||||
|
"Outcome patch must bump state_version so preview cache is invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── outcome propagation into preview bundle ───────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolution_note_preview_reflects_outcome_after_patch(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""End-to-end: preview before outcome != preview after outcome; new preview
|
||||||
|
bundle includes failure_reason; state_version was bumped between the two.
|
||||||
|
|
||||||
|
The LLM is stubbed so the test is deterministic. The stub returns whatever
|
||||||
|
the user-message content is, which means the captured call args reflect
|
||||||
|
what the bundle actually contained.
|
||||||
|
"""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
distinct_failure_reason = "DISTINCT-FAILURE-REASON-XYZZY-42"
|
||||||
|
|
||||||
|
calls_made: list[str] = []
|
||||||
|
|
||||||
|
async def fake_generate_text(system_prompt, messages, max_tokens):
|
||||||
|
user_content = messages[0]["content"]
|
||||||
|
calls_made.append(user_content)
|
||||||
|
# Return markdown that includes the user-message bundle verbatim so we
|
||||||
|
# can assert the bundle shape without inspecting mock internals.
|
||||||
|
return (
|
||||||
|
f"## Problem\ntest\n\n## What we confirmed\n(none)\n\n"
|
||||||
|
f"## Root cause\ntest\n\n## Resolution\nBUNDLE_CONTENT={user_content}",
|
||||||
|
100,
|
||||||
|
50,
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_text = AsyncMock(side_effect=fake_generate_text)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.resolution_note_generator.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
# Preview A — before any outcome recorded (status = "proposed").
|
||||||
|
r_a = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r_a.status_code == 200
|
||||||
|
markdown_a = r_a.json()["markdown"]
|
||||||
|
version_a = r_a.json()["state_version"]
|
||||||
|
assert r_a.json()["from_cache"] is False
|
||||||
|
|
||||||
|
# Record an applied_failed outcome with a distinctive reason.
|
||||||
|
r_patch = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
json={"outcome": "applied_failed", "notes": distinct_failure_reason},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r_patch.status_code == 200
|
||||||
|
|
||||||
|
# Preview B — must be a cache miss because state_version changed.
|
||||||
|
r_b = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r_b.status_code == 200
|
||||||
|
markdown_b = r_b.json()["markdown"]
|
||||||
|
version_b = r_b.json()["state_version"]
|
||||||
|
assert r_b.json()["from_cache"] is False, (
|
||||||
|
"Preview after outcome patch must be a cache miss (state_version changed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# State version increased between the two previews.
|
||||||
|
assert version_b > version_a, (
|
||||||
|
f"state_version should have increased; got {version_a} → {version_b}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Markdown differs between the two previews.
|
||||||
|
assert markdown_a != markdown_b, (
|
||||||
|
"Regenerated preview after outcome patch should differ from pre-outcome preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The bundle passed to the LLM for preview B includes the outcome fields.
|
||||||
|
assert len(calls_made) == 2, f"Expected 2 LLM calls (one per preview); got {len(calls_made)}"
|
||||||
|
bundle_b = calls_made[1]
|
||||||
|
assert "applied_failed" in bundle_b, (
|
||||||
|
"Bundle for second preview should include 'Outcome status: applied_failed'"
|
||||||
|
)
|
||||||
|
assert distinct_failure_reason in bundle_b, (
|
||||||
|
"Bundle for second preview should include the failure_reason text"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Apply endpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_stamps_applied_at(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""POST /apply stamps applied_at and bumps state_version."""
|
||||||
|
from uuid import UUID
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
result = await test_db.execute(
|
||||||
|
select(AISession).where(AISession.id == UUID(session_id))
|
||||||
|
)
|
||||||
|
session_obj = result.scalar_one()
|
||||||
|
initial_version = session_obj.state_version
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["applied_at"] is not None, "applied_at must be set after /apply"
|
||||||
|
assert body["status"] == "proposed", "status must remain 'proposed' after /apply"
|
||||||
|
|
||||||
|
await test_db.refresh(session_obj)
|
||||||
|
assert session_obj.state_version == initial_version + 1, (
|
||||||
|
"/apply must bump state_version so preview cache is invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_is_idempotent(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""Second POST /apply returns 200 with applied_at unchanged (no double-bump)."""
|
||||||
|
from uuid import UUID
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r1 = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
applied_at_first = r1.json()["applied_at"]
|
||||||
|
|
||||||
|
result = await test_db.execute(
|
||||||
|
select(AISession).where(AISession.id == UUID(session_id))
|
||||||
|
)
|
||||||
|
session_obj = result.scalar_one()
|
||||||
|
version_after_first = session_obj.state_version
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["applied_at"] == applied_at_first, (
|
||||||
|
"applied_at must not change on second /apply call"
|
||||||
|
)
|
||||||
|
|
||||||
|
await test_db.refresh(session_obj)
|
||||||
|
assert session_obj.state_version == version_after_first, (
|
||||||
|
"state_version must not be bumped a second time on idempotent /apply"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_rejects_non_proposed(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""POST /apply returns 409 when fix status is 'applied_success'."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
# Advance the fix to a terminal status via the outcome endpoint.
|
||||||
|
r_outcome = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
)
|
||||||
|
assert r_outcome.status_code == 200
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_rejects_dismissed(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""POST /apply returns 409 when fix status is 'dismissed'."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
r_outcome = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "dismissed"},
|
||||||
|
)
|
||||||
|
assert r_outcome.status_code == 200
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI outcome proposal: clear / reject ───────────────────────────────────────
|
||||||
|
|
||||||
|
async def _make_session_with_fix_and_proposal(test_db, user) -> tuple[str, str]:
|
||||||
|
"""Create an AISession + fix with a populated ai_outcome_proposal."""
|
||||||
|
from uuid import UUID as _UUID
|
||||||
|
session = AISession(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "proposal clear test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="Flush DNS cache",
|
||||||
|
description="Run ipconfig /flushdns on the affected host.",
|
||||||
|
confidence_pct=74,
|
||||||
|
ai_outcome_proposal={"fix_id": str(session.id), "outcome": "success", "reason": "User confirmed resolved"},
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(fix)
|
||||||
|
|
||||||
|
return str(session.id), str(fix.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outcome_patch_clears_ai_proposal(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""PATCH /outcome clears ai_outcome_proposal regardless of which outcome is written."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
|
||||||
|
|
||||||
|
# Verify the proposal is set before the patch.
|
||||||
|
from uuid import UUID
|
||||||
|
result = await test_db.execute(
|
||||||
|
select(SessionSuggestedFix).where(SessionSuggestedFix.id == UUID(fix_id))
|
||||||
|
)
|
||||||
|
fix_before = result.scalar_one()
|
||||||
|
assert fix_before.ai_outcome_proposal is not None
|
||||||
|
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"outcome": "applied_success"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ai_outcome_proposal"] is None, (
|
||||||
|
"PATCH /outcome must clear ai_outcome_proposal on any terminal action"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_ai_proposal_clears_field(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""DELETE /ai-outcome-proposal clears the field without changing status."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
|
||||||
|
|
||||||
|
r = await client.delete(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ai_outcome_proposal"] is None, (
|
||||||
|
"DELETE /ai-outcome-proposal must clear the field"
|
||||||
|
)
|
||||||
|
assert body["status"] == "proposed", (
|
||||||
|
"DELETE /ai-outcome-proposal must not change fix status"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_ai_proposal_when_none_is_idempotent(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""DELETE /ai-outcome-proposal returns 200 even when the field is already null."""
|
||||||
|
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
# Fix created by _make_session_with_fix has ai_outcome_proposal=None.
|
||||||
|
r = await client.delete(
|
||||||
|
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["ai_outcome_proposal"] is None
|
||||||
91
backend/tests/test_fix_outcome_marker.py
Normal file
91
backend/tests/test_fix_outcome_marker.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Unit tests for the [FIX_OUTCOME] marker parser."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.unified_chat_service import _parse_fix_outcome_marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_success_outcome():
|
||||||
|
ai = (
|
||||||
|
"Great news — that confirms the root cause.\n\n"
|
||||||
|
"[FIX_OUTCOME]\n"
|
||||||
|
'{"fix_id":"11111111-1111-1111-1111-111111111111",'
|
||||||
|
'"outcome":"success","reason":"user said the fix worked"}\n'
|
||||||
|
"[/FIX_OUTCOME]\n"
|
||||||
|
)
|
||||||
|
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert "[FIX_OUTCOME]" not in cleaned
|
||||||
|
assert "confirms the root cause" in cleaned
|
||||||
|
assert parsed == {
|
||||||
|
"fix_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"outcome": "success",
|
||||||
|
"reason": "user said the fix worked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_failure_outcome():
|
||||||
|
ai = (
|
||||||
|
"[FIX_OUTCOME]\n"
|
||||||
|
'{"fix_id":"22222222-2222-2222-2222-222222222222",'
|
||||||
|
'"outcome":"failure","reason":"user reports still broken"}\n'
|
||||||
|
"[/FIX_OUTCOME]"
|
||||||
|
)
|
||||||
|
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert "[FIX_OUTCOME]" not in cleaned
|
||||||
|
assert parsed["outcome"] == "failure"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_marker_returns_none():
|
||||||
|
ai = "no marker here"
|
||||||
|
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert cleaned == ai
|
||||||
|
assert parsed is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_json_is_dropped():
|
||||||
|
ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]"
|
||||||
|
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert "[FIX_OUTCOME]" not in cleaned
|
||||||
|
assert parsed is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_outcome_rejected():
|
||||||
|
ai = (
|
||||||
|
"[FIX_OUTCOME]\n"
|
||||||
|
'{"fix_id":"33333333-3333-3333-3333-333333333333",'
|
||||||
|
'"outcome":"maybe","reason":"x"}\n'
|
||||||
|
"[/FIX_OUTCOME]"
|
||||||
|
)
|
||||||
|
_, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert parsed is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_block_wins_when_multiple():
|
||||||
|
ai = (
|
||||||
|
"[FIX_OUTCOME]\n"
|
||||||
|
'{"fix_id":"44444444-4444-4444-4444-444444444444",'
|
||||||
|
'"outcome":"failure","reason":"first"}\n'
|
||||||
|
"[/FIX_OUTCOME]\n"
|
||||||
|
"[FIX_OUTCOME]\n"
|
||||||
|
'{"fix_id":"55555555-5555-5555-5555-555555555555",'
|
||||||
|
'"outcome":"success","reason":"second"}\n'
|
||||||
|
"[/FIX_OUTCOME]"
|
||||||
|
)
|
||||||
|
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert "[FIX_OUTCOME]" not in cleaned
|
||||||
|
assert parsed["fix_id"] == "55555555-5555-5555-5555-555555555555"
|
||||||
|
assert parsed["outcome"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_partial_outcome():
|
||||||
|
ai = (
|
||||||
|
"[FIX_OUTCOME]\n"
|
||||||
|
'{"fix_id":"66666666-6666-6666-6666-666666666666",'
|
||||||
|
'"outcome":"partial","reason":"user ran cred clear only"}\n'
|
||||||
|
"[/FIX_OUTCOME]"
|
||||||
|
)
|
||||||
|
_, parsed = _parse_fix_outcome_marker(ai)
|
||||||
|
assert parsed == {
|
||||||
|
"fix_id": "66666666-6666-6666-6666-666666666666",
|
||||||
|
"outcome": "partial",
|
||||||
|
"reason": "user ran cred clear only",
|
||||||
|
}
|
||||||
120
backend/tests/test_fix_script_endpoint.py
Normal file
120
backend/tests/test_fix_script_endpoint.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_session_with_fix(
|
||||||
|
test_db, user, *, status: str = "proposed", with_script: bool = False,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Create a pilot session + suggested fix for tests. Returns (sid, fid)."""
|
||||||
|
session = AISession(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="tshoot",
|
||||||
|
intake_type="psa_ticket",
|
||||||
|
intake_content={},
|
||||||
|
title="QA",
|
||||||
|
status="active",
|
||||||
|
confidence_tier="exploring",
|
||||||
|
confidence_score=0.0,
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
id=uuid4(),
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
title="QA: test fix",
|
||||||
|
description="desc",
|
||||||
|
confidence_pct=80,
|
||||||
|
status=status,
|
||||||
|
ai_drafted_script="pre-existing" if with_script else None,
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
return str(session.id), str(fix.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_script_happy_path(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
sid, fid = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||||||
|
json={"ai_drafted_script": "Write-Host 'hello'"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ai_drafted_script"] == "Write-Host 'hello'"
|
||||||
|
assert body["applied_at"] is None # draft != apply
|
||||||
|
assert body["status"] == "proposed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_script_bumps_state_version(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
sid, fid = await _make_session_with_fix(test_db, test_user)
|
||||||
|
before = await test_db.scalar(
|
||||||
|
select(AISession.state_version).where(AISession.id == UUID(sid))
|
||||||
|
)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||||||
|
json={"ai_drafted_script": "echo hi"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
after = await test_db.scalar(
|
||||||
|
select(AISession.state_version).where(AISession.id == UUID(sid))
|
||||||
|
)
|
||||||
|
assert after == (before or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_script_rejects_terminal_fix(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
sid, fid = await _make_session_with_fix(test_db, test_user, status="applied_success")
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||||||
|
json={"ai_drafted_script": "echo hi"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_script_rejects_empty_body(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
sid, fid = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||||||
|
json={"ai_drafted_script": ""},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 422 # pydantic min_length=1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_script_404_on_wrong_session(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
_, fid = await _make_session_with_fix(test_db, test_user)
|
||||||
|
wrong_sid = str(uuid4())
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{wrong_sid}/suggested-fixes/{fid}/script",
|
||||||
|
json={"ai_drafted_script": "echo hi"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
277
backend/tests/test_phase5_inline_script.py
Normal file
277
backend/tests/test_phase5_inline_script.py
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"""API + service tests for Phase 5 inline Script Generator integration.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- TemplateExtractionService: well-formed, fallback on bad output, missing-key fallback.
|
||||||
|
- /suggested-fixes/{fix_id}/decision side effects:
|
||||||
|
* one_off returns rendered_script, no draft_templates row.
|
||||||
|
* draft_template returns rendered_script + draft_template_id, draft persisted.
|
||||||
|
* build_template returns redirect_path.
|
||||||
|
* dismissed (Phase 3) still works.
|
||||||
|
- 400 when ai_drafted_script is missing for a non-template fix.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.draft_template import DraftTemplate
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
from app.services.template_extraction_service import (
|
||||||
|
_fallback,
|
||||||
|
_parse_response,
|
||||||
|
extract_parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_preview_cache():
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
yield
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_session_with_fix(
|
||||||
|
test_db, user, *, with_template_id: bool = False, with_drafted_script: bool = True,
|
||||||
|
) -> tuple[AISession, SessionSuggestedFix]:
|
||||||
|
session = AISession(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "phase 5 test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="Reset cached creds",
|
||||||
|
description="Clearing the cached credential...",
|
||||||
|
confidence_pct=85,
|
||||||
|
ai_drafted_script=(
|
||||||
|
'cmdkey /delete:"outlook.office365.com"\n'
|
||||||
|
'Restart-Process -Name OUTLOOK'
|
||||||
|
) if with_drafted_script else None,
|
||||||
|
ai_drafted_parameters={"target_user": "jsmith"} if with_drafted_script else None,
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(session)
|
||||||
|
await test_db.refresh(fix)
|
||||||
|
return session, fix
|
||||||
|
|
||||||
|
|
||||||
|
# ── TemplateExtractionService: parse + fallback ───────────────────────────
|
||||||
|
|
||||||
|
class TestParseResponse:
|
||||||
|
def test_well_formed(self):
|
||||||
|
raw = (
|
||||||
|
'{"parameters": [{"key":"host","label":"Host","type":"text",'
|
||||||
|
'"inferred_from":"session fact"}],'
|
||||||
|
'"templated_body":"Get-Service -ComputerName {{ host }}"}'
|
||||||
|
)
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result["parameters"]) == 1
|
||||||
|
assert result["parameters"][0]["key"] == "host"
|
||||||
|
assert result["templated_body"].endswith("{{ host }}")
|
||||||
|
|
||||||
|
def test_strips_fences(self):
|
||||||
|
raw = '```json\n{"parameters": [], "templated_body": "x"}\n```'
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result is not None and result["parameters"] == []
|
||||||
|
|
||||||
|
def test_invalid_key_dropped(self):
|
||||||
|
# Capital letters and dashes in key names violate snake_case — drop.
|
||||||
|
raw = (
|
||||||
|
'{"parameters":[{"key":"BadKey-Name","type":"text"}],'
|
||||||
|
'"templated_body":"x"}'
|
||||||
|
)
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result is not None and result["parameters"] == []
|
||||||
|
|
||||||
|
def test_unknown_type_falls_back_to_text(self):
|
||||||
|
raw = (
|
||||||
|
'{"parameters":[{"key":"x","type":"weird"}],"templated_body":"x"}'
|
||||||
|
)
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result is not None and result["parameters"][0]["type"] == "text"
|
||||||
|
|
||||||
|
def test_malformed_json_returns_none(self):
|
||||||
|
assert _parse_response("not json") is None
|
||||||
|
|
||||||
|
def test_non_dict_returns_none(self):
|
||||||
|
assert _parse_response('["a","b"]') is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_parameters_round_trip_failure_uses_fallback():
|
||||||
|
"""Templated_body referencing an undeclared placeholder triggers fallback."""
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_json = AsyncMock(return_value=(
|
||||||
|
# Declares parameter `host` but the body references `port` too.
|
||||||
|
'{"parameters":[{"key":"host","label":"Host","type":"text"}],'
|
||||||
|
'"templated_body":"Get-Service -ComputerName {{ host }} -Port {{ port }}"}',
|
||||||
|
100, 50,
|
||||||
|
))
|
||||||
|
with patch(
|
||||||
|
"app.services.template_extraction_service.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
result = await extract_parameters(
|
||||||
|
script_body="Get-Service -ComputerName srv01 -Port 8080",
|
||||||
|
)
|
||||||
|
fb = _fallback("Get-Service -ComputerName srv01 -Port 8080")
|
||||||
|
assert result == fb
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_parameters_llm_exception_uses_fallback():
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_json = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
|
with patch(
|
||||||
|
"app.services.template_extraction_service.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
result = await extract_parameters(script_body="echo hello")
|
||||||
|
assert result == _fallback("echo hello")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Decision endpoint: one_off ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_off_returns_rendered_script_no_draft(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "one_off"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["user_decision"] == "one_off"
|
||||||
|
assert body["rendered_script"] is not None
|
||||||
|
assert "cmdkey" in body["rendered_script"]
|
||||||
|
assert body["draft_template_id"] is None
|
||||||
|
assert body["redirect_path"] is None
|
||||||
|
|
||||||
|
# No draft_templates row should have been created.
|
||||||
|
rows = (
|
||||||
|
await test_db.execute(select(DraftTemplate).where(DraftTemplate.source_session_id == session.id))
|
||||||
|
).scalars().all()
|
||||||
|
assert list(rows) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Decision endpoint: draft_template ─────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_template_creates_draft_with_extracted_params(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_json = AsyncMock(return_value=(
|
||||||
|
'{"parameters":[{"key":"target_user","label":"Target User","type":"text",'
|
||||||
|
'"inferred_from":"session fact"}],'
|
||||||
|
'"templated_body":"cmdkey /delete:\\"outlook.office365.com\\"\\n'
|
||||||
|
'Restart-Process -Name OUTLOOK"}',
|
||||||
|
80, 60,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.template_extraction_service.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "draft_template"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["user_decision"] == "draft_template"
|
||||||
|
assert body["rendered_script"] is not None
|
||||||
|
assert body["draft_template_id"] is not None
|
||||||
|
assert body["redirect_path"] is None
|
||||||
|
|
||||||
|
drafts = (
|
||||||
|
await test_db.execute(select(DraftTemplate).where(DraftTemplate.source_session_id == session.id))
|
||||||
|
).scalars().all()
|
||||||
|
drafts = list(drafts)
|
||||||
|
assert len(drafts) == 1
|
||||||
|
draft = drafts[0]
|
||||||
|
assert draft.status == "pending"
|
||||||
|
assert draft.proposed_name == fix.title
|
||||||
|
proposed = draft.proposed_parameters.get("parameters") if isinstance(draft.proposed_parameters, dict) else None
|
||||||
|
assert isinstance(proposed, list) and len(proposed) == 1
|
||||||
|
assert proposed[0]["key"] == "target_user"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Decision endpoint: build_template ─────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_template_returns_redirect_path(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "build_template"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["redirect_path"] is not None
|
||||||
|
assert str(session.id) in body["redirect_path"]
|
||||||
|
assert str(fix.id) in body["redirect_path"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Decision endpoint: 400 when no drafted script ─────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_off_without_drafted_script_returns_400(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""A template-matched fix takes the dedicated /scripts/generate path; trying
|
||||||
|
to one_off it via this endpoint without an ai_drafted_script must surface
|
||||||
|
a clear client-error, not silently render nothing."""
|
||||||
|
session, fix = await _make_session_with_fix(test_db, test_user, with_drafted_script=False)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "one_off"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "ai_drafted_script" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Decision endpoint: edited script overrides ai_drafted_script ──────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edited_script_overrides_ai_drafted(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session, fix = await _make_session_with_fix(test_db, test_user)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"decision": "one_off",
|
||||||
|
"edited_script": "Get-Service -Name Dnscache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["rendered_script"] == "Get-Service -Name Dnscache"
|
||||||
295
backend/tests/test_phase6_draft_templates.py
Normal file
295
backend/tests/test_phase6_draft_templates.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""API tests for the FlowPilot Phase 6 post-resolve templatization flow.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GET /api/v1/draft-templates list with pending_only filter.
|
||||||
|
- POST /{id}/accept → creates script_templates row with provenance fields,
|
||||||
|
marks draft accepted + promoted_template_id set.
|
||||||
|
- POST /{id}/reject → marks rejected.
|
||||||
|
- 409 when accepting or rejecting a non-pending draft.
|
||||||
|
- Category validation (400 on unknown category_id).
|
||||||
|
- GET/PATCH /accounts/me/preferences round-trip.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.account_settings import AccountSettings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.draft_template import DraftTemplate
|
||||||
|
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_draft(
|
||||||
|
test_db,
|
||||||
|
user,
|
||||||
|
*,
|
||||||
|
proposed_name: str = "Test draft",
|
||||||
|
status_: str = "pending",
|
||||||
|
with_psa_ticket: bool = False,
|
||||||
|
) -> tuple[AISession, DraftTemplate]:
|
||||||
|
session = AISession(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "phase 6 test"},
|
||||||
|
status="resolved",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
psa_ticket_id="48307" if with_psa_ticket else None,
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
draft = DraftTemplate(
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
source_session_id=session.id,
|
||||||
|
source_user_id=user["user_data"]["id"],
|
||||||
|
script_body='Do-Something -Target {{ target_name }}\n',
|
||||||
|
proposed_parameters={
|
||||||
|
"parameters": [
|
||||||
|
{"key": "target_name", "label": "Target Name", "type": "text"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
proposed_name=proposed_name,
|
||||||
|
status=status_,
|
||||||
|
)
|
||||||
|
test_db.add(draft)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(draft)
|
||||||
|
return session, draft
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_category(test_db) -> ScriptCategory:
|
||||||
|
cat = ScriptCategory(
|
||||||
|
name="Phase 6 Test Category",
|
||||||
|
slug=f"phase-6-test-{datetime.now(timezone.utc).timestamp()}",
|
||||||
|
description="test",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
test_db.add(cat)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(cat)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
# ── List ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_pending_only_default(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
await _make_draft(test_db, test_user, proposed_name="pending-a", status_="pending")
|
||||||
|
await _make_draft(test_db, test_user, proposed_name="accepted-b", status_="accepted")
|
||||||
|
|
||||||
|
r = await client.get("/api/v1/draft-templates", headers=auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
drafts = r.json()["drafts"]
|
||||||
|
names = {d["proposed_name"] for d in drafts}
|
||||||
|
assert "pending-a" in names
|
||||||
|
assert "accepted-b" not in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_with_pending_only_false_includes_all(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
await _make_draft(test_db, test_user, proposed_name="pending-c", status_="pending")
|
||||||
|
await _make_draft(test_db, test_user, proposed_name="rejected-d", status_="rejected")
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
"/api/v1/draft-templates?pending_only=false", headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = {d["proposed_name"] for d in r.json()["drafts"]}
|
||||||
|
assert {"pending-c", "rejected-d"}.issubset(names)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accept ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_creates_template_with_provenance(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session, draft = await _make_draft(test_db, test_user, with_psa_ticket=True)
|
||||||
|
cat = await _make_category(test_db)
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Do Something On Target",
|
||||||
|
"category_id": str(cat.id),
|
||||||
|
"description": "promoted from phase 6 test",
|
||||||
|
"parameters_schema": {
|
||||||
|
"parameters": [
|
||||||
|
{"key": "target_name", "label": "Target", "field_type": "text"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["draft_id"] == str(draft.id)
|
||||||
|
assert body["promoted_template_id"] is not None
|
||||||
|
assert body["template_slug"] == "do-something-on-target"
|
||||||
|
|
||||||
|
# Draft row is now accepted with the promoted template ID set.
|
||||||
|
await test_db.refresh(draft)
|
||||||
|
assert draft.status == "accepted"
|
||||||
|
assert draft.promoted_template_id == UUID(body["promoted_template_id"])
|
||||||
|
assert draft.resolved_at is not None
|
||||||
|
|
||||||
|
# New template row exists with provenance fields populated.
|
||||||
|
tpl_result = await test_db.execute(
|
||||||
|
select(ScriptTemplate).where(ScriptTemplate.id == UUID(body["promoted_template_id"]))
|
||||||
|
)
|
||||||
|
tpl = tpl_result.scalar_one()
|
||||||
|
assert tpl.source_session_id == session.id
|
||||||
|
assert tpl.source_user_id == UUID(test_user["user_data"]["id"])
|
||||||
|
assert tpl.source_ticket_ref == "CW #48307"
|
||||||
|
assert tpl.script_body == draft.script_body # edited_body was not supplied
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_with_edited_body_overrides_draft(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
_, draft = await _make_draft(test_db, test_user)
|
||||||
|
cat = await _make_category(test_db)
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Edited Body Test",
|
||||||
|
"category_id": str(cat.id),
|
||||||
|
"parameters_schema": {"parameters": []},
|
||||||
|
"edited_body": 'Write-Host "edited final version"\n',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
tpl = (
|
||||||
|
await test_db.execute(
|
||||||
|
select(ScriptTemplate).where(
|
||||||
|
ScriptTemplate.id == UUID(r.json()["promoted_template_id"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert tpl.script_body == 'Write-Host "edited final version"\n'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_rejects_unknown_category(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
_, draft = await _make_draft(test_db, test_user)
|
||||||
|
bogus_cat = "00000000-0000-0000-0000-000000000000"
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "x",
|
||||||
|
"category_id": bogus_cat,
|
||||||
|
"parameters_schema": {"parameters": []},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_already_accepted_returns_409(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
_, draft = await _make_draft(test_db, test_user, status_="accepted")
|
||||||
|
cat = await _make_category(test_db)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "x",
|
||||||
|
"category_id": str(cat.id),
|
||||||
|
"parameters_schema": {"parameters": []},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reject ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_marks_draft_rejected(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
_, draft = await _make_draft(test_db, test_user)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "rejected"
|
||||||
|
await test_db.refresh(draft)
|
||||||
|
assert draft.status == "rejected"
|
||||||
|
assert draft.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_already_accepted_returns_409(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
_, draft = await _make_draft(test_db, test_user, status_="accepted")
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ── Preferences ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_preferences_empty_by_default(
|
||||||
|
client: AsyncClient, auth_headers,
|
||||||
|
):
|
||||||
|
r = await client.get("/api/v1/accounts/me/preferences", headers=auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["preferences"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_preferences_merges_keys(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
# First write: one key.
|
||||||
|
r = await client.patch(
|
||||||
|
"/api/v1/accounts/me/preferences",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"preferences": {"templatize_prompt_enabled": False}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["preferences"]["templatize_prompt_enabled"] is False
|
||||||
|
|
||||||
|
# Second write: different key — first must be preserved (merge semantics).
|
||||||
|
r2 = await client.patch(
|
||||||
|
"/api/v1/accounts/me/preferences",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"preferences": {"cw_resolved_status_id": 42}},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
prefs = r2.json()["preferences"]
|
||||||
|
assert prefs["templatize_prompt_enabled"] is False
|
||||||
|
assert prefs["cw_resolved_status_id"] == 42
|
||||||
|
|
||||||
|
# Stored on the account_settings row.
|
||||||
|
stored = (
|
||||||
|
await test_db.execute(
|
||||||
|
select(AccountSettings.preferences).where(
|
||||||
|
AccountSettings.account_id == UUID(test_user["user_data"]["account_id"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert stored["templatize_prompt_enabled"] is False
|
||||||
|
assert stored["cw_resolved_status_id"] == 42
|
||||||
184
backend/tests/test_prompt_anti_parrot.py
Normal file
184
backend/tests/test_prompt_anti_parrot.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""Guardrail: literal output payloads must not live in any LLM system prompt.
|
||||||
|
|
||||||
|
This test exists because the same anti-pattern bit us twice in the same
|
||||||
|
day: a worked example with literal content (Outlook + jsmith + literal
|
||||||
|
JSON; full DNS troubleshooting tree) sitting inside a `*_PROMPT` constant
|
||||||
|
caused Claude to recite that content on unrelated tickets, making the
|
||||||
|
task lane look like it was leaking previous-session data.
|
||||||
|
|
||||||
|
The fix is structural: every output example in a system prompt must use
|
||||||
|
`<placeholder>` or `<...>` syntax, never literal field values, command
|
||||||
|
names, hostnames, or usernames that the model could parrot. Format
|
||||||
|
examples that need real-looking content live in few-shot messages
|
||||||
|
(separate file, separate code path, model treats them as past behavior),
|
||||||
|
not in system prompts.
|
||||||
|
|
||||||
|
Failure messages here name the constant + line; fix by replacing the
|
||||||
|
literal payload with a placeholder schema, or by moving the example
|
||||||
|
out of the system prompt entirely.
|
||||||
|
|
||||||
|
See CLAUDE.md → Critical Lessons → "Don't put literal payloads in
|
||||||
|
system prompts" for the longer rationale.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import pkgutil
|
||||||
|
import re
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Modules to scan. We deliberately import the modules (not just walk source
|
||||||
|
# files) so we get the actual string values of `*_PROMPT` constants — which
|
||||||
|
# may be assembled from concat / .format() / f-strings.
|
||||||
|
_MODULE_PACKAGES = ("app.services", "app.core")
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_prompt_constants() -> Iterator[tuple[str, str, str]]:
|
||||||
|
"""Yield (module_name, constant_name, value) for every uppercase string
|
||||||
|
constant whose name ends in `_PROMPT` (or `_SCHEMA`/`_PROTOCOL`/`_FORMAT`
|
||||||
|
— same anti-pattern risk).
|
||||||
|
|
||||||
|
Skips modules that fail to import to keep the test resilient when an
|
||||||
|
individual module has unrelated breakage.
|
||||||
|
"""
|
||||||
|
suffixes = ("_PROMPT", "_SCHEMA", "_PROTOCOL", "_FORMAT", "_CONTEXT")
|
||||||
|
for pkg_name in _MODULE_PACKAGES:
|
||||||
|
pkg = importlib.import_module(pkg_name)
|
||||||
|
for mod_info in pkgutil.iter_modules(pkg.__path__, prefix=f"{pkg_name}."):
|
||||||
|
try:
|
||||||
|
mod = importlib.import_module(mod_info.name)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for name, value in inspect.getmembers(mod):
|
||||||
|
if not name.isupper() or not name.endswith(suffixes):
|
||||||
|
continue
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
yield mod_info.name, name, value
|
||||||
|
|
||||||
|
|
||||||
|
# ── The forbidden patterns ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# A literal username pattern that Claude has historically parroted across
|
||||||
|
# unrelated tickets. The list isn't exhaustive — it's the exact strings
|
||||||
|
# we've seen leak. Add to it if a new one shows up in production.
|
||||||
|
_FORBIDDEN_LITERAL_TOKENS: tuple[str, ...] = (
|
||||||
|
"jsmith", # leaked from an Outlook/AD example
|
||||||
|
"DC01", # leaked from an intake-form example
|
||||||
|
"ADSync", # leaked from a commands-array example
|
||||||
|
"Dnscache", # leaked from a DNS troubleshooting tree example
|
||||||
|
"google.com", # leaked from a DNS troubleshooting tree example
|
||||||
|
"Outlook keeps", "Teams drops", # specific phrasings from a worked Outlook/WiFi example
|
||||||
|
)
|
||||||
|
|
||||||
|
# Marker-with-payload patterns. A `[QUESTIONS]\n[{...JSON with real field values...}]`
|
||||||
|
# block in a prompt is the highest-risk shape — the model treats it as a
|
||||||
|
# canonical response template. We allow placeholder content (anything inside
|
||||||
|
# angle brackets `<...>` is treated as a placeholder, not a literal).
|
||||||
|
#
|
||||||
|
# Restrictions on the regex (to avoid false positives where the marker name
|
||||||
|
# appears in prose like "include [QUESTIONS] markers"):
|
||||||
|
# - opening tag must be at start of string OR preceded by newline/whitespace
|
||||||
|
# AND followed by newline+JSON-ish content
|
||||||
|
# - block content must START with `[` or `{` after optional whitespace,
|
||||||
|
# so prose blocks (like the closing-tag-distance regex match across
|
||||||
|
# markdown headings) are excluded
|
||||||
|
_MARKER_BLOCK_RE = re.compile(
|
||||||
|
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|FIX_OUTCOME|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
|
||||||
|
r"\s*\n" # forced newline before content
|
||||||
|
r"(\s*[\[{][\s\S]*?)" # content must start with [ or {
|
||||||
|
r"\s*\n\[/\1\]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Heuristic: only flag JSON VALUES, not JSON KEYS. Keys are followed by `:`,
|
||||||
|
# values come after `: ` (object value) or are bare strings inside an array.
|
||||||
|
# The shape we're defending against is `{"text": "Is this user on a laptop?"}` —
|
||||||
|
# the value `"Is this user on a laptop?"` is a literal payload the model will
|
||||||
|
# recite. Keys like `"text"` are part of the schema and must stay literal.
|
||||||
|
#
|
||||||
|
# Matches a quoted string that has at least 3 chars, no angle brackets, and
|
||||||
|
# is followed by a JSON value-terminator (`,` `]` `}`) — i.e. NOT followed
|
||||||
|
# by `:` (which would mark it as a key).
|
||||||
|
_QUOTED_VALUE_RE = re.compile(
|
||||||
|
r'"([^"<>][^"<>]{2,}?)"\s*(?=[,\]\}])'
|
||||||
|
)
|
||||||
|
# Substrings that, if PRESENT in the candidate value, indicate it's a
|
||||||
|
# placeholder marker rather than literal output. Be strict — broad markers
|
||||||
|
# like "?" alone would whitelist any sentence ending in a question mark,
|
||||||
|
# defeating the test's purpose.
|
||||||
|
_PLACEHOLDER_HINTS = ("...", "snake_case", "kebab-case", "<", "TODO")
|
||||||
|
# Schema enum-like values that are part of the format spec, not parrotable text.
|
||||||
|
_ALLOWED_ENUM_VALUES = frozenset({
|
||||||
|
"text", "password", "select", "boolean", "number", "textarea", "multi_text",
|
||||||
|
"powershell", "bash", "cmd", "python",
|
||||||
|
"question", "diagnostic_check", "user_note", "ai_synthesis",
|
||||||
|
"decision", "action", "solution", "procedure_step", "section_header", "procedure_end",
|
||||||
|
"step", "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _block_has_literal_payload(block_body: str) -> tuple[bool, str | None]:
|
||||||
|
"""Return (True, offending_string) if the marker block looks like literal output."""
|
||||||
|
for m in _QUOTED_VALUE_RE.finditer(block_body):
|
||||||
|
s = m.group(1).strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
# Pure placeholder hints — accept.
|
||||||
|
if any(h in s for h in _PLACEHOLDER_HINTS):
|
||||||
|
continue
|
||||||
|
# Pipe-separated enum like `text|password|select` — schema spec.
|
||||||
|
if "|" in s:
|
||||||
|
continue
|
||||||
|
# Single-word enum value we explicitly allow.
|
||||||
|
if s in _ALLOWED_ENUM_VALUES:
|
||||||
|
continue
|
||||||
|
# JSON ellipsis-style placeholders, ".." etc.
|
||||||
|
if all(c in "._" for c in s):
|
||||||
|
continue
|
||||||
|
return True, s
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_no_known_leaked_literal_tokens_in_prompts() -> None:
|
||||||
|
"""Constants must not contain strings the model has historically parroted.
|
||||||
|
|
||||||
|
Adding a new entry to _FORBIDDEN_LITERAL_TOKENS after a production leak is
|
||||||
|
the right way to extend coverage — keep this list as the audit trail.
|
||||||
|
"""
|
||||||
|
failures: list[str] = []
|
||||||
|
for module_name, const_name, value in _iter_prompt_constants():
|
||||||
|
for token in _FORBIDDEN_LITERAL_TOKENS:
|
||||||
|
if token in value:
|
||||||
|
failures.append(
|
||||||
|
f"{module_name}.{const_name} contains forbidden literal token "
|
||||||
|
f"{token!r} — replace with a <placeholder>. See CLAUDE.md → "
|
||||||
|
f"'Don't put literal payloads in system prompts'."
|
||||||
|
)
|
||||||
|
assert not failures, "\n".join(failures)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marker_blocks_in_prompts_use_placeholders_not_literal_payloads() -> None:
|
||||||
|
"""Every marker block in a system prompt must contain placeholders only.
|
||||||
|
|
||||||
|
A block like `[QUESTIONS]\\n[{"text": "Is this user on a laptop or desktop?"}]\\n[/QUESTIONS]`
|
||||||
|
will be recited verbatim by Claude on unrelated tickets. Use angle-bracket
|
||||||
|
placeholders instead: `[{"text": "<one short, specific question>"}]`.
|
||||||
|
"""
|
||||||
|
failures: list[str] = []
|
||||||
|
for module_name, const_name, value in _iter_prompt_constants():
|
||||||
|
for m in _MARKER_BLOCK_RE.finditer(value):
|
||||||
|
marker = m.group(1)
|
||||||
|
body = m.group(2)
|
||||||
|
has_literal, offender = _block_has_literal_payload(body)
|
||||||
|
if has_literal:
|
||||||
|
failures.append(
|
||||||
|
f"{module_name}.{const_name}: [{marker}] block contains literal "
|
||||||
|
f"payload string {offender!r}. Replace with a <placeholder>. "
|
||||||
|
f"See CLAUDE.md → 'Don't put literal payloads in system prompts'."
|
||||||
|
)
|
||||||
|
assert not failures, "\n".join(failures)
|
||||||
55
backend/tests/test_psa_tickets.py
Normal file
55
backend/tests/test_psa_tickets.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# backend/tests/test_psa_tickets.py
|
||||||
|
"""Routing and auth tests for new ticket management endpoints."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ticket_requires_auth(client):
|
||||||
|
"""POST /tickets returns 401 without auth."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/integrations/psa/tickets",
|
||||||
|
json={
|
||||||
|
"summary": "Test", "company_id": 1, "board_id": 1,
|
||||||
|
"status_id": 1, "priority_id": 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_resources_requires_auth(client):
|
||||||
|
response = await client.get("/api/v1/integrations/psa/tickets/1/resources")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_tickets_returns_paginated_shape(client, auth_headers):
|
||||||
|
"""search endpoint returns TicketListResponse shape when no PSA connected."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/integrations/psa/tickets/search",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
# No PSA connection → 400 or 502; with PSA → 200
|
||||||
|
assert response.status_code in (200, 400, 502)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_status_requires_auth(client):
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/integrations/psa/tickets/1/status?status_id=5"
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ai_parse_requires_auth(client):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/integrations/psa/tickets/ai-parse",
|
||||||
|
json={"prompt": "New ticket for Acme"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
281
backend/tests/test_psa_writeback_phase4.py
Normal file
281
backend/tests/test_psa_writeback_phase4.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""API tests for the FlowPilot Phase 4 Resolve + Escalate writeback flow.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Local-only path when no PSA ticket is linked (markdown stored, status flipped,
|
||||||
|
no provider call).
|
||||||
|
- PSA post happy path (provider mocked).
|
||||||
|
- Status transition verified by re-fetch (happy path).
|
||||||
|
- Status verification failure surfaces 502 with a clear error body.
|
||||||
|
- 409 when trying to resolve an already-resolved session / escalate an
|
||||||
|
already-escalated one.
|
||||||
|
- Escalation parallel to resolution (same structure).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
||||||
|
from app.models.account_settings import AccountSettings
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.psa_connection import PsaConnection
|
||||||
|
from app.services.psa.types import NoteType, PSANote, PSATicket
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_preview_cache():
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
yield
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession:
|
||||||
|
session_kwargs: dict = dict(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "phase 4 test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
if with_psa:
|
||||||
|
# Fake connection — provider factory is patched in each test so we
|
||||||
|
# never touch a real CW instance.
|
||||||
|
from app.services.psa.encryption import encrypt_credentials
|
||||||
|
conn = PsaConnection(
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
provider="connectwise",
|
||||||
|
site_url="https://fake.cw.local",
|
||||||
|
company_id="TEST",
|
||||||
|
credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
test_db.add(conn)
|
||||||
|
await test_db.flush()
|
||||||
|
session_kwargs["psa_connection_id"] = conn.id
|
||||||
|
session_kwargs["psa_ticket_id"] = "48291"
|
||||||
|
|
||||||
|
session = AISession(**session_kwargs)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: local-only ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_local_only_when_no_psa_ticket(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=False)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "resolved_local"
|
||||||
|
assert body["session_status"] == "resolved"
|
||||||
|
assert body["external_id"] is None
|
||||||
|
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.status == "resolved"
|
||||||
|
assert session.resolution_note_markdown == "## Problem\nx\n\n## Resolution\nfixed"
|
||||||
|
assert session.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: happy path (PSA post + status transition verified) ────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_posts_to_psa_and_verifies_status(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
|
||||||
|
# Configure the Resolved status ID so the transition is attempted.
|
||||||
|
await AccountSettings.set_setting(
|
||||||
|
test_db, session.account_id, "cw_resolved_status_id", 42,
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
# Mock provider: post_note returns a fake note, update_ticket_status
|
||||||
|
# returns anything, get_ticket returns the new status_id (matches 42
|
||||||
|
# → verification passes).
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-777", text="...", note_type=NoteType.RESOLUTION, created_at=None,
|
||||||
|
))
|
||||||
|
fake_provider.update_ticket_status = AsyncMock(return_value=None)
|
||||||
|
fake_provider.get_ticket = AsyncMock(return_value=PSATicket(
|
||||||
|
id="48291", summary="t", status_id=42, status_name="Resolved",
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "resolved"
|
||||||
|
assert body["external_id"] == "cw-note-777"
|
||||||
|
assert body["verified_status_id"] == 42
|
||||||
|
assert body["verified_status_name"] == "Resolved"
|
||||||
|
|
||||||
|
# post_note must have used the RESOLUTION note type
|
||||||
|
fake_provider.post_note.assert_awaited_once()
|
||||||
|
called_note_type = fake_provider.post_note.await_args.kwargs["note_type"]
|
||||||
|
assert called_note_type == NoteType.RESOLUTION
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: status verification failure → 502 ──────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_surfaces_status_verification_failure(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""CW silently rejecting a status change must NOT report silent success."""
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
await AccountSettings.set_setting(
|
||||||
|
test_db, session.account_id, "cw_resolved_status_id", 42,
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-alpha", text="...", note_type=NoteType.RESOLUTION, created_at=None,
|
||||||
|
))
|
||||||
|
fake_provider.update_ticket_status = AsyncMock(return_value=None)
|
||||||
|
# get_ticket returns a DIFFERENT status_id — the transition didn't stick.
|
||||||
|
fake_provider.get_ticket = AsyncMock(return_value=PSATicket(
|
||||||
|
id="48291", summary="t", status_id=99, status_name="In Progress",
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert "did not verify" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: skip status transition when not configured ────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_skips_status_transition_when_unconfigured(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""No cw_resolved_status_id setting → post the note, don't touch status, not an error."""
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
# Deliberately no AccountSettings row.
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-beta", text="...", note_type=NoteType.RESOLUTION, created_at=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Resolution\nfixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "resolved"
|
||||||
|
assert body["verified_status_id"] is None
|
||||||
|
assert body["status_transition_skipped_reason"] is not None
|
||||||
|
fake_provider.update_ticket_status.assert_not_called()
|
||||||
|
fake_provider.get_ticket.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resolve: already-resolved → 409 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_rejects_already_resolved_session(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=False)
|
||||||
|
session.status = "resolved"
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "..."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ── Escalate: local-only + PSA parallels ────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escalate_local_only_when_no_psa_ticket(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=False)
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/escalation-package/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["outcome"] == "escalated_local"
|
||||||
|
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.status == "escalated"
|
||||||
|
assert session.escalation_package_markdown is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_escalate_posts_internal_note_to_psa(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""Escalation handoff posts as INTERNAL_ANALYSIS (not customer-visible)."""
|
||||||
|
session = await _make_session(test_db, test_user, with_psa=True)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.post_note = AsyncMock(return_value=PSANote(
|
||||||
|
id="cw-note-esc", text="...", note_type=NoteType.INTERNAL_ANALYSIS, created_at=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.psa_writeback_service.get_provider_for_connection",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/escalation-package/post",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"markdown": "## Problem\nx\n\n## Suggested next steps\n- try X"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["outcome"] == "escalated"
|
||||||
|
assert body["external_id"] == "cw-note-esc"
|
||||||
|
|
||||||
|
# Handoff packages are internal — must NOT be posted with RESOLUTION or DESCRIPTION flags.
|
||||||
|
called = fake_provider.post_note.await_args.kwargs
|
||||||
|
assert called["note_type"] == NoteType.INTERNAL_ANALYSIS
|
||||||
@@ -11,30 +11,57 @@ Tests bypass FastAPI entirely — raw asyncpg connections only.
|
|||||||
MUST FAIL before Task 10 (RLS migration) and PASS after it.
|
MUST FAIL before Task 10 (RLS migration) and PASS after it.
|
||||||
|
|
||||||
Run with:
|
Run with:
|
||||||
DB_APP_ROLE_PASSWORD=app_secret_change_me pytest tests/test_rls_isolation.py -v
|
RUN_RLS_TESTS=1 DB_APP_ROLE_PASSWORD=app_secret_change_me pytest tests/test_rls_isolation.py -v
|
||||||
|
|
||||||
The test DB is patherly_test (matches conftest.py default).
|
The test DB comes from DATABASE_TEST_URL, matching conftest.py.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
# All tests in this module use module-scoped async fixtures (admin_conn,
|
# All tests in this module use module-scoped async fixtures (admin_conn,
|
||||||
# seed_rls_test_data) which run on the module event loop. Without this marker,
|
# seed_rls_test_data) which run on the module event loop. Without this marker,
|
||||||
# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing
|
# pytest-asyncio 0.23+ defaults tests to function-scoped loops, causing
|
||||||
# "Future attached to a different loop" errors on the asyncpg connections.
|
# "Future attached to a different loop" errors on the asyncpg connections.
|
||||||
pytestmark = pytest.mark.asyncio(loop_scope="module")
|
pytestmark = [
|
||||||
|
pytest.mark.asyncio(loop_scope="module"),
|
||||||
|
pytest.mark.rls,
|
||||||
|
]
|
||||||
|
|
||||||
_DB_HOST = os.getenv("TEST_DB_HOST", "localhost")
|
_DATABASE_TEST_URL = os.getenv(
|
||||||
_DB_PORT = int(os.getenv("TEST_DB_PORT", "5432"))
|
"DATABASE_TEST_URL",
|
||||||
_DB_NAME = os.getenv("TEST_DB_NAME", "patherly_test") # matches conftest.py
|
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
|
||||||
|
)
|
||||||
|
_DATABASE_TEST_URL_ASYNCPG = _DATABASE_TEST_URL.replace(
|
||||||
|
"postgresql+asyncpg://",
|
||||||
|
"postgresql://",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
_DATABASE_TEST_URL_SYNC = _DATABASE_TEST_URL_ASYNCPG
|
||||||
|
_TEST_DB_PARTS = urlsplit(_DATABASE_TEST_URL_ASYNCPG)
|
||||||
|
|
||||||
|
_DB_HOST = os.getenv("TEST_DB_HOST", _TEST_DB_PARTS.hostname or "localhost")
|
||||||
|
_DB_PORT = int(os.getenv("TEST_DB_PORT", str(_TEST_DB_PARTS.port or 5432)))
|
||||||
|
_DB_NAME = os.getenv(
|
||||||
|
"TEST_DB_NAME",
|
||||||
|
unquote(_TEST_DB_PARTS.path.lstrip("/") or "resolutionflow_test"),
|
||||||
|
)
|
||||||
|
_ADMIN_USER = os.getenv(
|
||||||
|
"TEST_DB_ADMIN_USER",
|
||||||
|
unquote(_TEST_DB_PARTS.username or "postgres"),
|
||||||
|
)
|
||||||
|
_ADMIN_PASSWORD = os.getenv(
|
||||||
|
"TEST_DB_ADMIN_PASSWORD",
|
||||||
|
unquote(_TEST_DB_PARTS.password or "postgres"),
|
||||||
|
)
|
||||||
_APP_PASSWORD = os.getenv("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
|
_APP_PASSWORD = os.getenv("DB_APP_ROLE_PASSWORD", "app_secret_change_me")
|
||||||
_ADMIN_DSN = f"postgresql://postgres:postgres@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}"
|
|
||||||
|
|
||||||
PLATFORM_ACCOUNT_ID = "00000000-0000-0000-0000-000000000001"
|
PLATFORM_ACCOUNT_ID = "00000000-0000-0000-0000-000000000001"
|
||||||
ACCOUNT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
ACCOUNT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||||
@@ -55,23 +82,33 @@ def _ensure_rls_schema():
|
|||||||
the full migration-managed schema (including RLS policies) is in place.
|
the full migration-managed schema (including RLS policies) is in place.
|
||||||
"""
|
"""
|
||||||
backend_dir = Path(__file__).parent.parent
|
backend_dir = Path(__file__).parent.parent
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DATABASE_URL"] = _DATABASE_TEST_URL
|
||||||
|
env["DATABASE_URL_SYNC"] = _DATABASE_TEST_URL_SYNC
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
[sys.executable, "-m", "alembic", "upgrade", "head"],
|
||||||
cwd=backend_dir,
|
cwd=backend_dir,
|
||||||
|
env=env,
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest_asyncio.fixture(scope="module", loop_scope="module")
|
||||||
async def admin_conn(_ensure_rls_schema):
|
async def admin_conn(_ensure_rls_schema):
|
||||||
"""Superuser asyncpg connection for fixture setup and teardown."""
|
"""Superuser asyncpg connection for fixture setup and teardown."""
|
||||||
conn = await asyncpg.connect(_ADMIN_DSN)
|
conn = await asyncpg.connect(
|
||||||
|
host=_DB_HOST,
|
||||||
|
port=_DB_PORT,
|
||||||
|
database=_DB_NAME,
|
||||||
|
user=_ADMIN_USER,
|
||||||
|
password=_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
yield conn
|
yield conn
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest_asyncio.fixture(scope="module", loop_scope="module", autouse=True)
|
||||||
async def seed_rls_test_data(admin_conn):
|
async def seed_rls_test_data(admin_conn):
|
||||||
"""
|
"""
|
||||||
Create two isolated test accounts, one user per account, and one private
|
Create two isolated test accounts, one user per account, and one private
|
||||||
@@ -154,7 +191,7 @@ async def seed_rls_test_data(admin_conn):
|
|||||||
await admin_conn.execute("DELETE FROM tree_tags WHERE slug = 'rls-global-tag'")
|
await admin_conn.execute("DELETE FROM tree_tags WHERE slug = 'rls-global-tag'")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest_asyncio.fixture(loop_scope="module")
|
||||||
async def conn_a():
|
async def conn_a():
|
||||||
"""App-role connection, tenant context = Account A."""
|
"""App-role connection, tenant context = Account A."""
|
||||||
conn = await asyncpg.connect(
|
conn = await asyncpg.connect(
|
||||||
@@ -168,7 +205,7 @@ async def conn_a():
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest_asyncio.fixture(loop_scope="module")
|
||||||
async def conn_b():
|
async def conn_b():
|
||||||
"""App-role connection, tenant context = Account B."""
|
"""App-role connection, tenant context = Account B."""
|
||||||
conn = await asyncpg.connect(
|
conn = await asyncpg.connect(
|
||||||
@@ -182,7 +219,7 @@ async def conn_b():
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest_asyncio.fixture(loop_scope="module")
|
||||||
async def conn_no_context():
|
async def conn_no_context():
|
||||||
"""App-role connection with NO tenant context set."""
|
"""App-role connection with NO tenant context set."""
|
||||||
conn = await asyncpg.connect(
|
conn = await asyncpg.connect(
|
||||||
@@ -288,7 +325,7 @@ async def test_flow_proposals_account_a_cannot_see_account_b(conn_a):
|
|||||||
# Phase 2 fixtures
|
# Phase 2 fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest_asyncio.fixture(scope="module", loop_scope="module")
|
||||||
async def session_row_ids(admin_conn):
|
async def session_row_ids(admin_conn):
|
||||||
"""
|
"""
|
||||||
Insert one `sessions` row and one `ai_sessions` row for each of
|
Insert one `sessions` row and one `ai_sessions` row for each of
|
||||||
@@ -644,13 +681,15 @@ async def test_psa_post_log_account_a_cannot_see_account_b(conn_a, session_row_i
|
|||||||
|
|
||||||
async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a):
|
async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_conn, conn_a):
|
||||||
"""Private/non-public steps owned by Account B must not be visible to Account A."""
|
"""Private/non-public steps owned by Account B must not be visible to Account A."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
private_step_id = str(uuid.uuid4())
|
private_step_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO step_library (
|
INSERT INTO step_library (
|
||||||
id, account_id, title, step_type, content,
|
id, account_id, created_by, title, step_type, content,
|
||||||
visibility, is_active, created_at, updated_at
|
visibility, is_active, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{private_step_id}', '{ACCOUNT_B_ID}', 'RLS Private Step', 'action',
|
'{private_step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
|
||||||
|
'RLS Private Step', 'action',
|
||||||
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -668,13 +707,15 @@ async def test_step_library_account_a_cannot_see_account_b_private_steps(admin_c
|
|||||||
|
|
||||||
async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a):
|
async def test_step_library_account_a_can_see_account_b_public_steps(admin_conn, conn_a):
|
||||||
"""Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility)."""
|
"""Public steps owned by Account B MUST be visible to Account A (cross-tenant visibility)."""
|
||||||
|
user_b_id = await _get_user_b_id(admin_conn)
|
||||||
public_step_id = str(uuid.uuid4())
|
public_step_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO step_library (
|
INSERT INTO step_library (
|
||||||
id, account_id, title, step_type, content,
|
id, account_id, created_by, title, step_type, content,
|
||||||
visibility, is_active, created_at, updated_at
|
visibility, is_active, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{public_step_id}', '{ACCOUNT_B_ID}', 'RLS Public Step', 'action',
|
'{public_step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
|
||||||
|
'RLS Public Step', 'action',
|
||||||
'{{}}'::jsonb, 'public', TRUE, NOW(), NOW()
|
'{{}}'::jsonb, 'public', TRUE, NOW(), NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -728,10 +769,11 @@ async def test_step_ratings_account_a_cannot_see_account_b(admin_conn, conn_a):
|
|||||||
step_id = str(uuid.uuid4())
|
step_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO step_library (
|
INSERT INTO step_library (
|
||||||
id, account_id, title, step_type, content,
|
id, account_id, created_by, title, step_type, content,
|
||||||
visibility, is_active, created_at, updated_at
|
visibility, is_active, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 RLS Step', 'action',
|
'{step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
|
||||||
|
'Phase3 RLS Step', 'action',
|
||||||
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -768,10 +810,11 @@ async def test_step_usage_log_account_a_cannot_see_account_b(admin_conn, conn_a)
|
|||||||
step_id = str(uuid.uuid4())
|
step_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO step_library (
|
INSERT INTO step_library (
|
||||||
id, account_id, title, step_type, content,
|
id, account_id, created_by, title, step_type, content,
|
||||||
visibility, is_active, created_at, updated_at
|
visibility, is_active, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{step_id}', '{ACCOUNT_B_ID}', 'Phase3 Usage Step', 'action',
|
'{step_id}', '{ACCOUNT_B_ID}', '{user_b_id}',
|
||||||
|
'Phase3 Usage Step', 'action',
|
||||||
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
'{{}}'::jsonb, 'private', TRUE, NOW(), NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -971,10 +1014,10 @@ async def test_script_builder_sessions_account_a_cannot_see_account_b(admin_conn
|
|||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO script_builder_sessions (
|
INSERT INTO script_builder_sessions (
|
||||||
id, user_id, account_id, language, created_at, updated_at
|
id, user_id, account_id, language, origin, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
'{session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
'powershell', NOW(), NOW()
|
'powershell', 'standalone', NOW(), NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
try:
|
try:
|
||||||
@@ -1001,22 +1044,24 @@ async def test_ai_session_steps_account_a_cannot_see_account_b(admin_conn, conn_
|
|||||||
ai_session_id = str(uuid.uuid4())
|
ai_session_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO ai_sessions (
|
INSERT INTO ai_sessions (
|
||||||
id, user_id, account_id, flow_type, status, confidence_tier,
|
id, user_id, account_id, session_type, intake_type,
|
||||||
|
intake_content, status, confidence_tier, confidence_score,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{ai_session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
'{ai_session_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
'troubleshooting', 'active', 'guided', NOW(), NOW()
|
'guided', 'free_text', '{{}}'::jsonb, 'active', 'guided', 0.0,
|
||||||
|
NOW(), NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
step_id = str(uuid.uuid4())
|
step_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO ai_session_steps (
|
INSERT INTO ai_session_steps (
|
||||||
id, session_id, account_id, step_type, content,
|
id, session_id, account_id, step_order, step_type, content,
|
||||||
created_at
|
created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{step_id}', '{ai_session_id}', '{ACCOUNT_B_ID}',
|
'{step_id}', '{ai_session_id}', '{ACCOUNT_B_ID}',
|
||||||
'question', 'Phase4 RLS test step', NOW()
|
1, 'question', '{{"text": "Phase4 RLS test step"}}'::jsonb, NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
try:
|
try:
|
||||||
@@ -1040,11 +1085,11 @@ async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
|
|||||||
notif_id = str(uuid.uuid4())
|
notif_id = str(uuid.uuid4())
|
||||||
await admin_conn.execute(f"""
|
await admin_conn.execute(f"""
|
||||||
INSERT INTO notifications (
|
INSERT INTO notifications (
|
||||||
id, user_id, account_id, type, title, message,
|
id, user_id, account_id, event, title, body,
|
||||||
is_read, created_at
|
is_read, created_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'{notif_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
'{notif_id}', '{user_b_id}', '{ACCOUNT_B_ID}',
|
||||||
'info', 'Phase4 RLS Test', 'RLS isolation test notification',
|
'test_event', 'Phase4 RLS Test', 'RLS isolation test notification',
|
||||||
FALSE, NOW()
|
FALSE, NOW()
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -1055,4 +1100,3 @@ async def test_notifications_account_a_cannot_see_account_b(admin_conn, conn_a):
|
|||||||
assert len(rows) == 0, "Account A should not see Account B notifications"
|
assert len(rows) == 0, "Account A should not see Account B notifications"
|
||||||
finally:
|
finally:
|
||||||
await admin_conn.execute(f"DELETE FROM notifications WHERE id = '{notif_id}'")
|
await admin_conn.execute(f"DELETE FROM notifications WHERE id = '{notif_id}'")
|
||||||
|
|
||||||
|
|||||||
176
backend/tests/test_script_builder_inline.py
Normal file
176
backend/tests/test_script_builder_inline.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Integration tests for inline pilot_inline script_builder_session behavior.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline'
|
||||||
|
- Authorization: ai_session_id must belong to current user
|
||||||
|
- list_sessions + count_user_sessions default-scope to 'standalone'
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.script_builder_session import ScriptBuilderSession
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_pilot_session(test_db, user) -> str:
|
||||||
|
"""Helper: create a minimal pilot session owned by `user`.
|
||||||
|
|
||||||
|
Matches the existing pattern used by test_fix_outcome_endpoint.py.
|
||||||
|
`user` is the dict returned by the test_user fixture:
|
||||||
|
{"email": ..., "password": ..., "user_data": {"id": ..., "account_id": ..., ...}}
|
||||||
|
"""
|
||||||
|
user_id = user["user_data"]["id"]
|
||||||
|
account_id = user["user_data"]["account_id"]
|
||||||
|
session = AISession(
|
||||||
|
id=uuid4(), user_id=user_id, account_id=account_id,
|
||||||
|
session_type="tshoot", intake_type="psa_ticket",
|
||||||
|
intake_content={}, title="QA",
|
||||||
|
status="active", confidence_tier="exploring", confidence_score=0.0,
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.commit()
|
||||||
|
return str(session.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inline_create_is_idempotent(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""Second create with same (user, ai_session_id) returns the existing row."""
|
||||||
|
ai_session_id = await _make_pilot_session(test_db, test_user)
|
||||||
|
|
||||||
|
r1 = await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell", "origin": "pilot_inline",
|
||||||
|
"ai_session_id": ai_session_id},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r1.status_code in (200, 201), r1.text
|
||||||
|
first_id = r1.json()["id"]
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell", "origin": "pilot_inline",
|
||||||
|
"ai_session_id": ai_session_id},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r2.status_code in (200, 201)
|
||||||
|
assert r2.json()["id"] == first_id
|
||||||
|
|
||||||
|
# DB confirms only one row
|
||||||
|
row_count = await test_db.scalar(
|
||||||
|
select(func.count()).select_from(ScriptBuilderSession).where(
|
||||||
|
ScriptBuilderSession.user_id == test_user["user_data"]["id"],
|
||||||
|
ScriptBuilderSession.origin == "pilot_inline",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert row_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inline_requires_ai_session_id(
|
||||||
|
client: AsyncClient, auth_headers
|
||||||
|
):
|
||||||
|
"""origin='pilot_inline' without ai_session_id is rejected."""
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell", "origin": "pilot_inline"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "ai_session_id" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inline_ai_session_must_belong_to_caller(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""ai_session_id pointing at another user's session is rejected."""
|
||||||
|
# Create pilot session owned by a DIFFERENT user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.account import Account
|
||||||
|
other_account = Account(id=uuid4(), name="other", display_code="OTH-0001")
|
||||||
|
test_db.add(other_account)
|
||||||
|
await test_db.flush()
|
||||||
|
other_user = User(
|
||||||
|
id=uuid4(), email="other@example.com",
|
||||||
|
password_hash="x", name="Other", role="engineer",
|
||||||
|
is_super_admin=False, is_team_admin=False, is_active=True,
|
||||||
|
is_service_account=False, must_change_password=False,
|
||||||
|
account_id=other_account.id, account_role="engineer",
|
||||||
|
)
|
||||||
|
test_db.add(other_user)
|
||||||
|
await test_db.flush()
|
||||||
|
# Build user dict in the same shape as the test_user fixture
|
||||||
|
other_user_dict = {
|
||||||
|
"user_data": {"id": str(other_user.id), "account_id": str(other_account.id)}
|
||||||
|
}
|
||||||
|
other_session_id = await _make_pilot_session(test_db, other_user_dict)
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell", "origin": "pilot_inline",
|
||||||
|
"ai_session_id": other_session_id},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code in (403, 404), r.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_excludes_inline(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""GET /scripts/builder/sessions returns only standalone rows."""
|
||||||
|
ai_session_id = await _make_pilot_session(test_db, test_user)
|
||||||
|
|
||||||
|
# Create one inline session
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell", "origin": "pilot_inline",
|
||||||
|
"ai_session_id": ai_session_id},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
# Create one standalone session
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await client.get("/api/v1/scripts/builder/sessions", headers=auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# Depending on response shape, this may be a list or {"sessions": [...]}.
|
||||||
|
items = body if isinstance(body, list) else body.get("sessions", body.get("items", []))
|
||||||
|
# Response schema does not surface `origin`; len==1 is the only meaningful guard:
|
||||||
|
# inline row would push this to 2.
|
||||||
|
assert len(items) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inline_sessions_do_not_count_against_cap(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""Creating 5 pilot_inline sessions does not block a subsequent standalone."""
|
||||||
|
# Create 5 distinct pilot sessions and attach inline builder sessions to each
|
||||||
|
for _ in range(5):
|
||||||
|
ai_session_id = await _make_pilot_session(test_db, test_user)
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell", "origin": "pilot_inline",
|
||||||
|
"ai_session_id": ai_session_id},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code in (200, 201), r.text
|
||||||
|
|
||||||
|
# A standalone create should still succeed — inline sessions don't count
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/scripts/builder/sessions",
|
||||||
|
json={"language": "powershell"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code in (200, 201), r.text
|
||||||
455
backend/tests/test_session_facts_api.py
Normal file
455
backend/tests/test_session_facts_api.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
"""API + service tests for the FlowPilot Phase 2 "What we know" facts surface.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- /api/v1/ai-sessions/{id}/facts CRUD
|
||||||
|
- Editability rule (403 on PATCH for question/diagnostic_check facts)
|
||||||
|
- /facts/promote with `proposed_text` (no LLM call) and via synthesis (mocked)
|
||||||
|
- state_version increments on every fact write
|
||||||
|
- Stable-UUID assignment for pending_task_lane items
|
||||||
|
- [PROMOTE] marker parser shape
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_fact import SessionFact
|
||||||
|
from app.services.fact_synthesis_service import FactSynthesisService
|
||||||
|
from app.services.unified_chat_service import (
|
||||||
|
_assign_stable_task_lane_ids,
|
||||||
|
_parse_promote_marker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _make_session(test_db, user, *, pending_task_lane=None) -> AISession:
|
||||||
|
session = AISession(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
pending_task_lane=pending_task_lane,
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── [PROMOTE] marker parser ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestPromoteMarkerParser:
|
||||||
|
def test_no_marker_returns_unchanged(self):
|
||||||
|
text = "Just an analysis sentence."
|
||||||
|
cleaned, items = _parse_promote_marker(text)
|
||||||
|
assert cleaned == text
|
||||||
|
assert items is None
|
||||||
|
|
||||||
|
def test_single_block(self):
|
||||||
|
ref = uuid.uuid4()
|
||||||
|
text = (
|
||||||
|
"Some analysis.\n\n"
|
||||||
|
f'[PROMOTE]\n{{"source_type":"question","source_ref":"{ref}",'
|
||||||
|
'"text":"OWA login confirmed working","summary":"rules out tenant"}\n'
|
||||||
|
"[/PROMOTE]"
|
||||||
|
)
|
||||||
|
cleaned, items = _parse_promote_marker(text)
|
||||||
|
assert cleaned == "Some analysis."
|
||||||
|
assert items is not None and len(items) == 1
|
||||||
|
assert items[0]["source_type"] == "question"
|
||||||
|
assert items[0]["source_ref"] == ref
|
||||||
|
assert items[0]["text"] == "OWA login confirmed working"
|
||||||
|
assert items[0]["summary"] == "rules out tenant"
|
||||||
|
|
||||||
|
def test_multiple_blocks(self):
|
||||||
|
text = (
|
||||||
|
'[PROMOTE]\n{"source_type":"question","source_ref":null,'
|
||||||
|
'"text":"a","summary":"x"}\n[/PROMOTE]\n'
|
||||||
|
'[PROMOTE]\n{"source_type":"diagnostic_check","source_ref":null,'
|
||||||
|
'"text":"b","summary":"y"}\n[/PROMOTE]'
|
||||||
|
)
|
||||||
|
cleaned, items = _parse_promote_marker(text)
|
||||||
|
assert items is not None and len(items) == 2
|
||||||
|
assert items[0]["text"] == "a"
|
||||||
|
assert items[1]["text"] == "b"
|
||||||
|
assert "[PROMOTE]" not in cleaned
|
||||||
|
|
||||||
|
def test_ai_synthesis_strips_source_ref(self):
|
||||||
|
# The model should not provide source_ref for synthesis facts —
|
||||||
|
# the parser drops it defensively even if the model does.
|
||||||
|
ref = uuid.uuid4()
|
||||||
|
text = (
|
||||||
|
f'[PROMOTE]\n{{"source_type":"ai_synthesis","source_ref":"{ref}",'
|
||||||
|
'"text":"Combined finding","summary":"synth"}\n[/PROMOTE]'
|
||||||
|
)
|
||||||
|
_, items = _parse_promote_marker(text)
|
||||||
|
assert items is not None and items[0]["source_ref"] is None
|
||||||
|
|
||||||
|
def test_invalid_source_type_dropped(self):
|
||||||
|
text = (
|
||||||
|
'[PROMOTE]\n{"source_type":"bogus","text":"x"}\n[/PROMOTE]\n'
|
||||||
|
'[PROMOTE]\n{"source_type":"question","source_ref":null,"text":"good"}\n[/PROMOTE]'
|
||||||
|
)
|
||||||
|
_, items = _parse_promote_marker(text)
|
||||||
|
assert items is not None and len(items) == 1
|
||||||
|
assert items[0]["text"] == "good"
|
||||||
|
|
||||||
|
def test_missing_text_dropped(self):
|
||||||
|
text = '[PROMOTE]\n{"source_type":"question","source_ref":null,"text":""}\n[/PROMOTE]'
|
||||||
|
_, items = _parse_promote_marker(text)
|
||||||
|
assert items is None # empty list collapses to None
|
||||||
|
|
||||||
|
def test_invalid_uuid_drops_ref_keeps_item(self):
|
||||||
|
text = '[PROMOTE]\n{"source_type":"question","source_ref":"not-a-uuid","text":"keep"}\n[/PROMOTE]'
|
||||||
|
_, items = _parse_promote_marker(text)
|
||||||
|
assert items is not None and items[0]["source_ref"] is None
|
||||||
|
assert items[0]["text"] == "keep"
|
||||||
|
|
||||||
|
def test_malformed_json_dropped(self):
|
||||||
|
text = "[PROMOTE]\nnot json at all\n[/PROMOTE]"
|
||||||
|
cleaned, items = _parse_promote_marker(text)
|
||||||
|
assert items is None
|
||||||
|
# Block is still stripped from display so the engineer doesn't see it.
|
||||||
|
assert "[PROMOTE]" not in cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stable-UUID assignment ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestAssignStableTaskLaneIds:
|
||||||
|
def test_empty_prev_assigns_fresh_uuids(self):
|
||||||
|
qs, acts = _assign_stable_task_lane_ids(
|
||||||
|
None,
|
||||||
|
[{"text": "Q1", "context": "c1"}],
|
||||||
|
[{"label": "A1", "command": "cmd"}],
|
||||||
|
)
|
||||||
|
assert len(qs) == 1 and uuid.UUID(qs[0]["id"])
|
||||||
|
assert len(acts) == 1 and uuid.UUID(acts[0]["id"])
|
||||||
|
|
||||||
|
def test_prev_uuid_preserved_on_text_match(self):
|
||||||
|
qid = str(uuid.uuid4())
|
||||||
|
prev = {
|
||||||
|
"questions": [{"id": qid, "text": "Same text"}],
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
qs, _ = _assign_stable_task_lane_ids(prev, [{"text": "Same text"}], [])
|
||||||
|
assert qs[0]["id"] == qid
|
||||||
|
|
||||||
|
def test_prev_uuid_replaced_when_text_changes(self):
|
||||||
|
qid = str(uuid.uuid4())
|
||||||
|
prev = {"questions": [{"id": qid, "text": "Original"}], "actions": []}
|
||||||
|
qs, _ = _assign_stable_task_lane_ids(prev, [{"text": "Different"}], [])
|
||||||
|
assert qs[0]["id"] != qid
|
||||||
|
|
||||||
|
def test_action_label_match_preserves_uuid(self):
|
||||||
|
aid = str(uuid.uuid4())
|
||||||
|
prev = {"questions": [], "actions": [{"id": aid, "label": "Run X"}]}
|
||||||
|
_, acts = _assign_stable_task_lane_ids(prev, [], [{"label": "Run X"}])
|
||||||
|
assert acts[0]["id"] == aid
|
||||||
|
|
||||||
|
|
||||||
|
# ── FactSynthesisService.create_fact validation ─────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_fact_rejects_source_ref_for_user_note(test_db, test_user):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
svc = FactSynthesisService(test_db)
|
||||||
|
with pytest.raises(ValueError, match="source_ref must be None"):
|
||||||
|
await svc.create_fact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
user_id=session.user_id,
|
||||||
|
source_type="user_note",
|
||||||
|
text="x",
|
||||||
|
source_ref=uuid.uuid4(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_fact_rejects_invalid_source_type(test_db, test_user):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
svc = FactSynthesisService(test_db)
|
||||||
|
with pytest.raises(ValueError, match="Invalid source_type"):
|
||||||
|
await svc.create_fact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
user_id=session.user_id,
|
||||||
|
source_type="not_a_type",
|
||||||
|
text="x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_fact_bumps_state_version(test_db, test_user):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
initial_version = session.state_version
|
||||||
|
svc = FactSynthesisService(test_db)
|
||||||
|
await svc.create_fact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
user_id=session.user_id,
|
||||||
|
source_type="user_note",
|
||||||
|
text="A confirmed observation",
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.state_version == initial_version + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoint tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_facts_empty(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["facts"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_note_fact(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "Customer is on a laptop", "summary": "endpoint type"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["source_type"] == "user_note"
|
||||||
|
assert body["editable"] is True
|
||||||
|
assert body["source_ref"] is None
|
||||||
|
assert body["text"] == "Customer is on a laptop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_user_note_succeeds(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
create = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "original"},
|
||||||
|
)
|
||||||
|
fact_id = create.json()["id"]
|
||||||
|
|
||||||
|
patch_resp = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/{fact_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "edited", "summary": "new label"},
|
||||||
|
)
|
||||||
|
assert patch_resp.status_code == 200
|
||||||
|
assert patch_resp.json()["text"] == "edited"
|
||||||
|
assert patch_resp.json()["source_summary"] == "new label"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_question_fact_returns_403(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
"""Question/check-sourced facts must be edited at the source item, not the card."""
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
# Insert a question-sourced fact directly so the editability rule applies.
|
||||||
|
fact = SessionFact(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
text="Pre-existing question fact",
|
||||||
|
source_type="question",
|
||||||
|
source_ref=uuid.uuid4(),
|
||||||
|
created_by=session.user_id,
|
||||||
|
)
|
||||||
|
test_db.add(fact)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(fact)
|
||||||
|
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/{fact.id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "trying to edit"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_fact_soft_deletes(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
create = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "to be removed"},
|
||||||
|
)
|
||||||
|
fact_id = create.json()["id"]
|
||||||
|
|
||||||
|
del_resp = await client.delete(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/{fact_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 204
|
||||||
|
|
||||||
|
# Listed facts should not include the soft-deleted one.
|
||||||
|
list_resp = await client.get(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
assert all(f["id"] != fact_id for f in list_resp.json()["facts"])
|
||||||
|
|
||||||
|
# Row still exists in DB (deleted_at set), proving it was soft-deleted.
|
||||||
|
row = (
|
||||||
|
await test_db.execute(
|
||||||
|
select(SessionFact).where(SessionFact.id == uuid.UUID(fact_id))
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert row.deleted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_promote_with_proposed_text(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
qid = uuid.uuid4()
|
||||||
|
session = await _make_session(
|
||||||
|
test_db, test_user,
|
||||||
|
pending_task_lane={
|
||||||
|
"questions": [{"id": str(qid), "text": "Is OWA working?"}],
|
||||||
|
"actions": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/promote",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"source_type": "question",
|
||||||
|
"source_ref": str(qid),
|
||||||
|
"proposed_text": "OWA confirmed working for jsmith",
|
||||||
|
"proposed_summary": "rules out tenant/license",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["source_type"] == "question"
|
||||||
|
assert body["source_ref"] == str(qid)
|
||||||
|
assert body["editable"] is False # question-sourced facts are read-only at the card
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_promote_via_synthesis(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
qid = uuid.uuid4()
|
||||||
|
session = await _make_session(
|
||||||
|
test_db, test_user,
|
||||||
|
pending_task_lane={
|
||||||
|
"questions": [{"id": str(qid), "text": "Is the user on a laptop?"}],
|
||||||
|
"actions": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the LLM call to avoid hitting the network in tests.
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_json = AsyncMock(return_value=(
|
||||||
|
'{"text": "User confirmed on a laptop", "summary": "endpoint type"}',
|
||||||
|
50, 20,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.fact_synthesis_service.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/promote",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"source_type": "question",
|
||||||
|
"source_ref": str(qid),
|
||||||
|
"raw_input": "Yes, it's a Lenovo X1 Carbon",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["text"] == "User confirmed on a laptop"
|
||||||
|
assert resp.json()["source_summary"] == "endpoint type"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_promote_synthesis_returning_null_returns_422(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""When the synthesizer judges the input has no fact, the endpoint surfaces 422."""
|
||||||
|
qid = uuid.uuid4()
|
||||||
|
session = await _make_session(
|
||||||
|
test_db, test_user,
|
||||||
|
pending_task_lane={
|
||||||
|
"questions": [{"id": str(qid), "text": "Is OWA working?"}],
|
||||||
|
"actions": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_json = AsyncMock(return_value=(
|
||||||
|
'{"text": null, "summary": null}', 30, 10,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.fact_synthesis_service.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/promote",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"source_type": "question",
|
||||||
|
"source_ref": str(qid),
|
||||||
|
"raw_input": "unknown",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_promote_rejects_both_or_neither_inputs(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
# Neither
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/promote",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"source_type": "question"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
# Both
|
||||||
|
resp2 = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts/promote",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"source_type": "question",
|
||||||
|
"proposed_text": "x",
|
||||||
|
"raw_input": "y",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_state_version_bumps_on_create_via_endpoint(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
initial = session.state_version
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "a"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reload — refresh fetches the latest persisted row.
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.state_version == initial + 1
|
||||||
356
backend/tests/test_session_suggested_fixes_api.py
Normal file
356
backend/tests/test_session_suggested_fixes_api.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"""API + service tests for the FlowPilot Phase 3 suggested-fix + preview surface.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- /api/v1/ai-sessions/{id}/suggested-fixes/active (200 + 404)
|
||||||
|
- /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision (one_off,
|
||||||
|
draft_template, build_template, dismissed; 409 on dismissing a superseded
|
||||||
|
fix; state_version bump)
|
||||||
|
- /api/v1/ai-sessions/{id}/resolution-note/preview (LLM mocked; cache hit on
|
||||||
|
same state_version, miss after a fact write)
|
||||||
|
- [SUGGEST_FIX] marker parser shape
|
||||||
|
- _persist_suggested_fix supersession + state_version bump
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
||||||
|
from app.models.ai_session import AISession
|
||||||
|
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||||
|
from app.services.unified_chat_service import (
|
||||||
|
_parse_suggest_fix_marker,
|
||||||
|
_persist_suggested_fix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_preview_cache():
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
yield
|
||||||
|
_clear_preview_cache_for_tests()
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_session(test_db, user) -> AISession:
|
||||||
|
session = AISession(
|
||||||
|
user_id=user["user_data"]["id"],
|
||||||
|
account_id=user["user_data"]["account_id"],
|
||||||
|
session_type="chat",
|
||||||
|
intake_type="free_text",
|
||||||
|
intake_content={"text": "phase 3 test"},
|
||||||
|
status="active",
|
||||||
|
confidence_tier="discovery",
|
||||||
|
conversation_messages=[],
|
||||||
|
)
|
||||||
|
test_db.add(session)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ── [SUGGEST_FIX] parser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSuggestFixParser:
|
||||||
|
def test_no_marker(self):
|
||||||
|
cleaned, fix = _parse_suggest_fix_marker("just analysis")
|
||||||
|
assert cleaned == "just analysis"
|
||||||
|
assert fix is None
|
||||||
|
|
||||||
|
def test_well_formed_block(self):
|
||||||
|
text = (
|
||||||
|
"Analysis sentence.\n\n"
|
||||||
|
'[SUGGEST_FIX]\n'
|
||||||
|
'{"title": "Reset password", "description": "Stale credential.", '
|
||||||
|
'"confidence": 87, "script_template_slug": "reset-cw"}\n'
|
||||||
|
'[/SUGGEST_FIX]'
|
||||||
|
)
|
||||||
|
cleaned, fix = _parse_suggest_fix_marker(text)
|
||||||
|
assert cleaned == "Analysis sentence."
|
||||||
|
assert fix is not None
|
||||||
|
assert fix["title"] == "Reset password"
|
||||||
|
assert fix["confidence_pct"] == 87
|
||||||
|
assert fix["script_template_slug"] == "reset-cw"
|
||||||
|
assert fix["ai_drafted_script"] is None
|
||||||
|
|
||||||
|
def test_confidence_clamped_and_rounded(self):
|
||||||
|
text = (
|
||||||
|
'[SUGGEST_FIX]\n{"title":"x","description":"y","confidence":120.7}\n[/SUGGEST_FIX]'
|
||||||
|
)
|
||||||
|
_, fix = _parse_suggest_fix_marker(text)
|
||||||
|
assert fix is not None and fix["confidence_pct"] == 100
|
||||||
|
|
||||||
|
text2 = (
|
||||||
|
'[SUGGEST_FIX]\n{"title":"x","description":"y","confidence":-3}\n[/SUGGEST_FIX]'
|
||||||
|
)
|
||||||
|
_, fix2 = _parse_suggest_fix_marker(text2)
|
||||||
|
assert fix2 is not None and fix2["confidence_pct"] == 0
|
||||||
|
|
||||||
|
def test_only_last_block_wins(self):
|
||||||
|
# Stale early block plus a final intent — the parser keeps the LAST one.
|
||||||
|
text = (
|
||||||
|
'[SUGGEST_FIX]\n{"title":"old","description":"o","confidence":50}\n[/SUGGEST_FIX]\n'
|
||||||
|
'[SUGGEST_FIX]\n{"title":"new","description":"n","confidence":80}\n[/SUGGEST_FIX]'
|
||||||
|
)
|
||||||
|
cleaned, fix = _parse_suggest_fix_marker(text)
|
||||||
|
assert fix is not None and fix["title"] == "new"
|
||||||
|
assert "[SUGGEST_FIX]" not in cleaned
|
||||||
|
|
||||||
|
def test_missing_required_field_dropped(self):
|
||||||
|
text = '[SUGGEST_FIX]\n{"title":"only title"}\n[/SUGGEST_FIX]'
|
||||||
|
cleaned, fix = _parse_suggest_fix_marker(text)
|
||||||
|
assert fix is None
|
||||||
|
# Marker still stripped from display.
|
||||||
|
assert "[SUGGEST_FIX]" not in cleaned
|
||||||
|
|
||||||
|
def test_malformed_json_dropped(self):
|
||||||
|
text = "[SUGGEST_FIX]\nnot json\n[/SUGGEST_FIX]"
|
||||||
|
cleaned, fix = _parse_suggest_fix_marker(text)
|
||||||
|
assert fix is None
|
||||||
|
assert "[SUGGEST_FIX]" not in cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ── _persist_suggested_fix ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_supersedes_prior_active_and_bumps_state_version(test_db, test_user):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
initial_version = session.state_version
|
||||||
|
|
||||||
|
# Insert an existing active fix so we can verify supersession.
|
||||||
|
existing = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="Old fix",
|
||||||
|
description="prior",
|
||||||
|
confidence_pct=60,
|
||||||
|
)
|
||||||
|
test_db.add(existing)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
await _persist_suggested_fix(
|
||||||
|
db=test_db,
|
||||||
|
session=session,
|
||||||
|
fix={
|
||||||
|
"title": "New fix",
|
||||||
|
"description": "current best",
|
||||||
|
"confidence_pct": 88,
|
||||||
|
"script_template_slug": None,
|
||||||
|
"ai_drafted_script": None,
|
||||||
|
"ai_drafted_parameters": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
await test_db.refresh(existing)
|
||||||
|
await test_db.refresh(session)
|
||||||
|
|
||||||
|
assert existing.superseded_at is not None
|
||||||
|
assert session.state_version == initial_version + 1
|
||||||
|
|
||||||
|
# Exactly one active row remains — and it's the new one.
|
||||||
|
result = await test_db.execute(
|
||||||
|
select(SessionSuggestedFix).where(
|
||||||
|
SessionSuggestedFix.session_id == session.id,
|
||||||
|
SessionSuggestedFix.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
actives = list(result.scalars().all())
|
||||||
|
assert len(actives) == 1
|
||||||
|
assert actives[0].title == "New fix"
|
||||||
|
|
||||||
|
|
||||||
|
# ── /suggested-fixes/active endpoint ────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_active_returns_404_when_none(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
r = await client.get(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/active",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_active_returns_active_fix(client: AsyncClient, test_user, auth_headers, test_db):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="Active fix",
|
||||||
|
description="d",
|
||||||
|
confidence_pct=72,
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
r = await client.get(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/active",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["title"] == "Active fix"
|
||||||
|
assert body["confidence_pct"] == 72
|
||||||
|
assert body["superseded_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── /decision endpoint ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_decision_persists_and_bumps_state_version(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
initial_version = session.state_version
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="x",
|
||||||
|
description="y",
|
||||||
|
confidence_pct=50,
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "draft_template"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["user_decision"] == "draft_template"
|
||||||
|
|
||||||
|
await test_db.refresh(session)
|
||||||
|
assert session.state_version == initial_version + 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismissed_supersedes_the_fix(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="x",
|
||||||
|
description="y",
|
||||||
|
confidence_pct=50,
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "dismissed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
await test_db.refresh(fix)
|
||||||
|
assert fix.superseded_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismiss_already_superseded_returns_409(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
fix = SessionSuggestedFix(
|
||||||
|
session_id=session.id,
|
||||||
|
account_id=session.account_id,
|
||||||
|
title="x",
|
||||||
|
description="y",
|
||||||
|
confidence_pct=50,
|
||||||
|
superseded_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
test_db.add(fix)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"decision": "dismissed"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ── /resolution-note/preview endpoint ──────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_uses_state_version_cache(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_text = AsyncMock(return_value=(
|
||||||
|
"## Problem\nx\n\n## What we confirmed\n(none)\n\n## Root cause\ny\n\n## Resolution\nz",
|
||||||
|
100, 50,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.resolution_note_generator.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
# First call — cache miss, generates fresh.
|
||||||
|
r1 = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/preview",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["from_cache"] is False
|
||||||
|
assert fake_provider.generate_text.await_count == 1
|
||||||
|
|
||||||
|
# Second call, no state change — must hit the cache (no extra LLM call).
|
||||||
|
r2 = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/preview",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["from_cache"] is True
|
||||||
|
assert r2.json()["markdown"] == r1.json()["markdown"]
|
||||||
|
assert fake_provider.generate_text.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_invalidates_after_fact_write(
|
||||||
|
client: AsyncClient, test_user, auth_headers, test_db
|
||||||
|
):
|
||||||
|
"""A new fact bumps state_version → next preview is a fresh generation, not cached."""
|
||||||
|
session = await _make_session(test_db, test_user)
|
||||||
|
|
||||||
|
fake_provider = AsyncMock()
|
||||||
|
fake_provider.generate_text = AsyncMock(return_value=(
|
||||||
|
"## Problem\nx\n\n## What we confirmed\n(none)\n\n## Root cause\ny\n\n## Resolution\nz",
|
||||||
|
100, 50,
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.resolution_note_generator.get_ai_provider",
|
||||||
|
return_value=fake_provider,
|
||||||
|
):
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/preview",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert fake_provider.generate_text.await_count == 1
|
||||||
|
|
||||||
|
# Add a fact — bumps state_version on the session.
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/facts",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"text": "a confirmed observation"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Next preview must regenerate (cache key includes state_version).
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/ai-sessions/{session.id}/resolution-note/preview",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["from_cache"] is False
|
||||||
|
assert fake_provider.generate_text.await_count == 2
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
name: resolutionflow
|
name: resolutionflow
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg16
|
||||||
@@ -8,7 +9,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: resolutionflow
|
POSTGRES_DB: resolutionflow
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-5433}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- rf_postgres_data:/var/lib/postgresql/data
|
- rf_postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -22,53 +23,51 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: resolutionflow_backend
|
container_name: resolutionflow_backend
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ${REPO_ROOT}/backend:/app
|
||||||
environment:
|
environment:
|
||||||
- APP_NAME=ResolutionFlow
|
- APP_NAME=ResolutionFlow
|
||||||
- DEBUG=true
|
- DEBUG=true
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
|
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow
|
||||||
- DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
|
- DATABASE_URL_SYNC=postgresql://postgres:postgres@db:5432/resolutionflow
|
||||||
|
# Dedicated test database — pytest will refuse to run against any DB
|
||||||
|
# whose name doesn't contain 'test' (conftest.py safety assertion).
|
||||||
|
- DATABASE_TEST_URL=postgresql+asyncpg://postgres:postgres@db:5432/resolutionflow_test
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- ALGORITHM=HS256
|
- ALGORITHM=HS256
|
||||||
- ACCESS_TOKEN_EXPIRE_MINUTES=15
|
- ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
- REQUIRE_INVITE_CODE=true
|
||||||
- FEEDBACK_EMAIL=feedback@resolutionflow.com
|
- FEEDBACK_EMAIL=feedback@resolutionflow.com
|
||||||
- AI_PROVIDER=anthropic
|
- AI_PROVIDER=anthropic
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY}
|
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
|
||||||
- CORS_ORIGINS=["http://localhost:3000","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:5173","http://46.202.92.250:5173","http://46.202.92.250:3000","https://resolutionflow.com","https://www.resolutionflow.com"]
|
- ENABLE_MCP_MICROSOFT_LEARN=true
|
||||||
|
- FRONTEND_URL=http://docker-01:5173
|
||||||
|
- CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.rf-api.rule=Host(`dev.resolutionflow.com`) && PathPrefix(`/api`)"
|
|
||||||
- "traefik.http.routers.rf-api.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.rf-api.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.rf-api.loadbalancer.server.port=8000"
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
container_name: resolutionflow_frontend
|
container_name: resolutionflow_frontend
|
||||||
|
command: npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ${REPO_ROOT}/frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=https://dev.resolutionflow.com/
|
- VITE_API_URL=http://docker-01:8000
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.rf-frontend.rule=Host(`dev.resolutionflow.com`)"
|
|
||||||
- "traefik.http.routers.rf-frontend.middlewares=dev-auth"
|
|
||||||
- "traefik.http.middlewares.dev-auth.basicauth.users=chihlasm:$$apr1$$dJXUAZ3Y$$SsJm.K8fOjCeNMe4B70Bi0"
|
|
||||||
- "traefik.http.routers.rf-frontend.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.rf-frontend.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.rf-frontend.loadbalancer.server.port=5173"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
rf_postgres_data:
|
rf_postgres_data:
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||||
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
||||||
> **Status:** Phase 0 in progress. Phases 1–7 awaiting Phase 0 completion for the AI-dependent work; Phase 1 can run in parallel.
|
> **Status:** Phases 0–9 implemented. Phase 9 shipped the tabbed Script Builder integration (chat-region tab strip, `ScriptBuilderTab` controller with AI + Monaco editor modes, `InlineNoTemplateDialog` chat-region relocation, `PATCH /script` endpoint, `origin` discriminator migration reusing the existing `ai_session_id` FK, `applied_at` semantics correction, and `EscalateInterceptDialog` fourth "partial" choice). `tsc -b` and `npm run build` both clean.
|
||||||
> **Last updated:** April 17, 2026 (post-Codex plan review, reflects Phase 0 audit findings and in-flight implementation decisions)
|
> **Last updated:** April 24, 2026 (Phase 9 — Tabbed Script Builder — committed; handoff and migration spec updated)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,6 +30,10 @@ This document was originally written against a set of assumptions about the code
|
|||||||
- **`pending_task_lane` items do not have stable IDs today.** Phase 2 must assign stable UUIDs when questions/checks are first persisted. `session_facts.source_ref` points to those JSON item IDs.
|
- **`pending_task_lane` items do not have stable IDs today.** Phase 2 must assign stable UUIDs when questions/checks are first persisted. `session_facts.source_ref` points to those JSON item IDs.
|
||||||
- **`account_settings` table did not exist.** Phase 1 creates it with a JSONB `preferences` column; settings live in `preferences` until they need their own column.
|
- **`account_settings` table did not exist.** Phase 1 creates it with a JSONB `preferences` column; settings live in `preferences` until they need their own column.
|
||||||
- **`/tickets/ai-parse` endpoint does not exist.** Phase 0.2 became a doc-only note; no code change.
|
- **`/tickets/ai-parse` endpoint does not exist.** Phase 0.2 became a doc-only note; no code change.
|
||||||
|
- **`[PROMOTE]` marker uses JSON, not key:value.** The doc's original example showed
|
||||||
|
`key: value` lines; implementation uses a JSON object inside each `[PROMOTE]...[/PROMOTE]`
|
||||||
|
block (matching the `[QUESTIONS]` / `[ACTIONS]` parser pattern). Multi-line text values would
|
||||||
|
have been ambiguous in the key:value form. Section 8.1 below has been updated.
|
||||||
|
|
||||||
Any further drift found during implementation should be flagged by the implementer and reconciled in this doc before writing code that assumes the drifted spec.
|
Any further drift found during implementation should be flagged by the implementer and reconciled in this doc before writing code that assumes the drifted spec.
|
||||||
|
|
||||||
@@ -572,18 +576,15 @@ The existing FlowPilot / ResolutionAssist system prompt needs updates to emit th
|
|||||||
|
|
||||||
### 8.1 New marker: `[PROMOTE]`
|
### 8.1 New marker: `[PROMOTE]`
|
||||||
|
|
||||||
Used to surface facts to What we know. Syntax:
|
Used to surface facts to What we know. Syntax — each block contains a single JSON object; multiple blocks may appear in one response:
|
||||||
|
|
||||||
```
|
```
|
||||||
[PROMOTE]
|
[PROMOTE]
|
||||||
source_type: question
|
{"source_type": "question", "source_ref": "{task_lane_item_uuid}", "text": "OWA login and send/receive confirmed working for jsmith", "summary": "rules out tenant/license"}
|
||||||
source_ref: {task_lane_item_uuid}
|
|
||||||
text: OWA login and send/receive confirmed working for jsmith
|
|
||||||
summary: rules out tenant/license
|
|
||||||
[/PROMOTE]
|
[/PROMOTE]
|
||||||
```
|
```
|
||||||
|
|
||||||
The AI should emit `[PROMOTE]` blocks in the same message that answers or processes a question/check, so the fact appears in What we know simultaneously with the chat acknowledgment. `source_ref` points to the stable UUID of the task lane item being promoted (assigned in Phase 2).
|
The AI should emit `[PROMOTE]` blocks in the same message that answers or processes a question/check, so the fact appears in What we know simultaneously with the chat acknowledgment. `source_ref` points to the stable UUID of the task lane item being promoted (assigned in Phase 2). For `source_type: "ai_synthesis"`, omit `source_ref` (or set it to null) — the parser drops it defensively even if the model includes one.
|
||||||
|
|
||||||
### 8.2 New marker: `[SUGGEST_FIX]`
|
### 8.2 New marker: `[SUGGEST_FIX]`
|
||||||
|
|
||||||
@@ -709,6 +710,11 @@ git commit -m "feat(pilot): rename /assistant to /pilot, add session_facts/sugge
|
|||||||
- Attempt to PATCH a question-sourced fact → 403.
|
- Attempt to PATCH a question-sourced fact → 403.
|
||||||
- PATCH a user_note fact → succeeds.
|
- PATCH a user_note fact → succeeds.
|
||||||
|
|
||||||
|
**Verification deferred** — same constraint as Phase 0: no live dev environment was
|
||||||
|
available at authoring time. Backend pytest suite (`tests/test_session_facts_api.py`)
|
||||||
|
and the manual scenarios above must run when the dev env is up. Failures should be
|
||||||
|
treated as normal bugs, not blockers for Phase 3.
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat(pilot): add What we know section with fact synthesis and stable task-lane item IDs"
|
git commit -m "feat(pilot): add What we know section with fact synthesis and stable task-lane item IDs"
|
||||||
```
|
```
|
||||||
@@ -733,6 +739,15 @@ git commit -m "feat(pilot): add What we know section with fact synthesis and sta
|
|||||||
- Preview contains no hallucinated information not present in session state (human review of 5 real-ish sessions).
|
- Preview contains no hallucinated information not present in session state (human review of 5 real-ish sessions).
|
||||||
- Incrementing `state_version` invalidates the preview cache; reading the same version returns the cached markdown.
|
- Incrementing `state_version` invalidates the preview cache; reading the same version returns the cached markdown.
|
||||||
|
|
||||||
|
**Verified end-to-end** against the dev stack on 2026-04-22:
|
||||||
|
- `/suggested-fixes/active` → 404 when no fix; 200 with payload when one exists.
|
||||||
|
- Fact write bumps `state_version`; preview cache invalidates as expected.
|
||||||
|
- Sonnet generates well-formed four-section markdown grounded only in
|
||||||
|
provided facts (single-fact session correctly says "Root cause not
|
||||||
|
definitively isolated").
|
||||||
|
- Second consecutive preview call with no state change returns
|
||||||
|
`from_cache=true` and emits no LLM call.
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview with state_version caching"
|
git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview with state_version caching"
|
||||||
```
|
```
|
||||||
@@ -755,6 +770,20 @@ git commit -m "feat(pilot): add suggested fix tracking and Resolve note preview
|
|||||||
- Simulate CW silently rejecting a status change — verify the app surfaces an error, not silent success.
|
- Simulate CW silently rejecting a status change — verify the app surfaces an error, not silent success.
|
||||||
- Attempt to Resolve without a linked PSA ticket — session marks resolved locally without erroring; markdown stored in `resolution_note_markdown`.
|
- Attempt to Resolve without a linked PSA ticket — session marks resolved locally without erroring; markdown stored in `resolution_note_markdown`.
|
||||||
|
|
||||||
|
**Verified on 2026-04-22:**
|
||||||
|
- Local-only Resolve + Escalate confirmed end-to-end against the dev stack
|
||||||
|
(no PSA instance wired): markdown stored, session.status flips, 409 on
|
||||||
|
re-post.
|
||||||
|
- Escalation-package preview generates well-formed five-section markdown
|
||||||
|
from a single fact (real Sonnet); second preview call with no state
|
||||||
|
change returns `from_cache=true`, confirming the cache-kind separation
|
||||||
|
from resolution-note previews.
|
||||||
|
- PSA post + status-verification paths covered by mocked-provider pytest
|
||||||
|
cases: happy path, silent-rejection → 502 with clear detail, skipped
|
||||||
|
transition when `cw_resolved_status_id` unset, internal-analysis note
|
||||||
|
type used for escalation handoffs. Live CW round-trip still TODO once a
|
||||||
|
test instance is wired.
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback with status verification"
|
git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback with status verification"
|
||||||
```
|
```
|
||||||
@@ -779,6 +808,22 @@ git commit -m "feat(pilot): wire Resolve and Escalate to ConnectWise writeback w
|
|||||||
- `⌘K → "script"` anywhere in a session opens the generator directly.
|
- `⌘K → "script"` anywhere in a session opens the generator directly.
|
||||||
- Edge case: if the suggested fix's `script_template_id` points at a template that has been deleted, show the no-template three-option dialog with the AI-drafted script (do not error).
|
- Edge case: if the suggested fix's `script_template_id` points at a template that has been deleted, show the no-template three-option dialog with the AI-drafted script (do not error).
|
||||||
|
|
||||||
|
**Verified on 2026-04-22:**
|
||||||
|
- `one_off` returns rendered_script, no draft persisted.
|
||||||
|
- `draft_template` returns rendered_script + draft_template_id; real Sonnet-driven
|
||||||
|
TemplateExtractionService persists a `draft_templates` row with the fix's
|
||||||
|
title pre-filled and `status=pending`.
|
||||||
|
- `build_template` returns `redirect_path=/scripts/builder?from_session=…&fix=…`.
|
||||||
|
- Conservative extraction default works: a script with environment-agnostic
|
||||||
|
cmdlets (cmdkey, Restart-Process) yielded zero proposed parameters as
|
||||||
|
intended by the "prefer fewer parameters" rule.
|
||||||
|
- TemplateMatchPanel falls back gracefully on 404 (deleted template) by
|
||||||
|
surfacing a panel-level message; the engineer can dismiss the fix and
|
||||||
|
re-trigger the AI for a fresh suggestion.
|
||||||
|
- Cmd+K → "Open inline Script Generator" surfaces only when on a `/pilot/:id`
|
||||||
|
route; fires a window event the chat page subscribes to. No Resolve
|
||||||
|
shortcut added (per Section 14 decision).
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat(pilot): integrate Script Generator inline with suggested fixes"
|
git commit -m "feat(pilot): integrate Script Generator inline with suggested fixes"
|
||||||
```
|
```
|
||||||
@@ -804,6 +849,22 @@ git commit -m "feat(pilot): integrate Script Generator inline with suggested fix
|
|||||||
- Skip the prompt → draft marked rejected, Script Library shows no new template.
|
- Skip the prompt → draft marked rejected, Script Library shows no new template.
|
||||||
- Toggle "don't ask me again" → next session Resolve skips the prompt even with a pending draft.
|
- Toggle "don't ask me again" → next session Resolve skips the prompt even with a pending draft.
|
||||||
|
|
||||||
|
**Verified on 2026-04-22:**
|
||||||
|
- `GET /draft-templates?pending_only=true` returns pending rows; filter
|
||||||
|
flips the set to include accepted/rejected for audit views.
|
||||||
|
- `POST /{id}/accept` → creates `script_templates` row; `source_session_id`,
|
||||||
|
`source_user_id`, `source_ticket_ref` (e.g. "CW #99123") copied from
|
||||||
|
the source session so the Script Library provenance chip has its data.
|
||||||
|
Draft flips to `status='accepted'`, `promoted_template_id` populated,
|
||||||
|
`resolved_at` set. 409 on a re-accept.
|
||||||
|
- `POST /{id}/reject` → flips to `status='rejected'`, `resolved_at` set.
|
||||||
|
- `GET /accounts/me/preferences` → empty dict when no row; `PATCH`
|
||||||
|
merges keys into `preferences` JSONB (verified round-trip persistence
|
||||||
|
of `templatize_prompt_enabled: false`).
|
||||||
|
- Sidebar Scripts nav gains a badge reflecting the pending draft count
|
||||||
|
(fetched independently of the main sidebar stats endpoint so a
|
||||||
|
draft-endpoint failure doesn't break the rest of the sidebar).
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat(pilot): add post-resolve templatize prompt for draft templates"
|
git commit -m "feat(pilot): add post-resolve templatize prompt for draft templates"
|
||||||
```
|
```
|
||||||
@@ -830,6 +891,56 @@ git commit -m "feat(pilot): add post-resolve templatize prompt for draft templat
|
|||||||
git commit -m "feat(pilot): visual polish, empty/loading states, keyboard shortcuts"
|
git commit -m "feat(pilot): visual polish, empty/loading states, keyboard shortcuts"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Phase 8 — Fix Outcome Banner
|
||||||
|
|
||||||
|
**Plan and rationale:** [phase-8-fix-outcome-banner.md](phase-8-fix-outcome-banner.md)
|
||||||
|
|
||||||
|
**Mockups:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html), [mockups/07-verify-states.html](mockups/07-verify-states.html)
|
||||||
|
|
||||||
|
**What this phase does:** Removes the `SuggestedFix` card as the primary interaction point for fix application. Replaces it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that stays visible at the bottom of the conversation column regardless of task-lane scroll depth. Addresses the user-reported discoverability problem: *"the task lane fills up pretty quick … the suggested fix … is easily missed."*
|
||||||
|
|
||||||
|
**Key backend additions:**
|
||||||
|
- Six new columns on `session_suggested_fixes`: `status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`
|
||||||
|
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome` endpoint to record the engineer's decision
|
||||||
|
- `[FIX_OUTCOME]` marker in the FlowPilot system prompt, parsed by `unified_chat_service.py` to trigger the banner
|
||||||
|
|
||||||
|
**Key frontend additions:**
|
||||||
|
- `ProposalBanner` component (`frontend/src/components/pilot/ProposalBanner.tsx`) — slide-up banner anchored above the chat composer; shows fix title, confidence, and Accept / Dismiss / Escalate actions; auto-collapses after session resolves
|
||||||
|
- `EscalateInterceptDialog` — intercepts the Escalate action when a fix proposal is active, asking whether the engineer wants to note that the fix was attempted before escalating
|
||||||
|
|
||||||
|
**Commit range:** `cdd8bb0` (Phase 8 Task 1 start) through `8582d24`
|
||||||
|
|
||||||
|
```
|
||||||
|
git commit -m "feat(pilot): Phase 8 — fix outcome banner replaces task-lane SuggestedFix CTA"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 9 — Tabbed Script Builder
|
||||||
|
|
||||||
|
**Spec:** [phase-9-script-builder-tab.md](phase-9-script-builder-tab.md)
|
||||||
|
|
||||||
|
**Implementation plan:** [phase-9-implementation-plan.md](phase-9-implementation-plan.md)
|
||||||
|
|
||||||
|
**What this phase does:** Resolves open items #1 (NoTemplateDialog narrow-lane bug) and #3 (Tabbed Script Builder) from the Phase 6/7 backlog. The chat region gains a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip` + a new `ScriptBuilderTab` controller) that hosts two modes: an AI path reusing the existing (untouched) `ScriptBuilderChat`, and a "Write it myself" path using `ScriptBodyEditor` (Monaco). Engineer submit writes the drafted script back to `session_suggested_fixes.ai_drafted_script` via a new PATCH endpoint — `applied_at` is NOT stamped (a draft is not an application). Tabs use `display: none` toggling so chat scroll position, draft message, AI history, and Monaco buffer are all preserved across switches. `InlineNoTemplateDialog` is relocated from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper, eliminating the narrow-lane viewport-breakpoint collision that made the three-option grid unusable.
|
||||||
|
|
||||||
|
**Key backend additions:**
|
||||||
|
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script` — writes `ai_drafted_script` + `ai_drafted_parameters` without stamping `applied_at`; bumps `state_version` so Resolve/Escalate preview bundles regenerate; 409 on terminal fix status
|
||||||
|
- Alembic migration adds `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` to `script_builder_sessions` (CHECK enum `'standalone'|'pilot_inline'` + invariant `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL`); reuses the pre-existing `ai_session_id` FK rather than adding a new parent column; partial unique index `ux_script_builder_sessions_pilot_inline` on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency
|
||||||
|
- `POST /api/v1/scripts/builder/sessions` extended: accepts `origin` + `ai_session_id` with auth (pilot session must belong to caller); returns existing row on duplicate; race-safe via `IntegrityError` + re-read fallback; `list_sessions` and `count_user_sessions` default-scope to `origin='standalone'` so inline sessions don't pollute the dashboard or count against the 5-session cap
|
||||||
|
- `applied_at` semantics corrected: stamps only on run-declaring actions — `TemplateMatchPanel` "I ran this" click via new `onMarkRun` prop, and `NoTemplateDialog` decisions `one_off`/`draft_template` (both labelled "Run now, …"). `build_template` does NOT stamp. Script Builder tab Submit does NOT stamp. Banner `Apply` click no longer stamps directly
|
||||||
|
|
||||||
|
**Key frontend additions:**
|
||||||
|
- `ChatTabStrip` — `[Chat] [Script Builder ●]` header strip in the chat region when the active fix needs a drafted script (status proposed/applied_partial, no template, no drafted script)
|
||||||
|
- `ScriptBuilderTab` — new controller wrapping `ScriptBuilderChat` (AI mode) + `ScriptBodyEditor` (Monaco, "Write it myself" mode); get-or-create on mount; Submit calls `sessionSuggestedFixesApi.patchScript`
|
||||||
|
- `InlineNoTemplateDialog` — chat-region slide-up wrapper around the existing `NoTemplateDialog`; replaces the previous task-lane `bottomSlot` rendering of the drafted-script three-card decision
|
||||||
|
- `TemplateMatchPanel` gains `onMarkRun` optional prop + "✓ I ran this" primary button
|
||||||
|
- `EscalateInterceptDialog` gains a fourth "I applied some of it — partial" choice (dispatches `applied_partial` via the existing `FixOutcome` pass-through)
|
||||||
|
|
||||||
|
**Commit range:** `5bcb7aa` (Phase 9 Task 1 start) through `faf1d8d`
|
||||||
|
|
||||||
|
```
|
||||||
|
git commit -m "feat(pilot): Phase 9 — tabbed Script Builder + InlineNoTemplateDialog relocation"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Design system reference
|
## 10. Design system reference
|
||||||
|
|||||||
165
docs/FlowAssist_Migration/Issues/phase-8-review-issues.md
Normal file
165
docs/FlowAssist_Migration/Issues/phase-8-review-issues.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Phase 8 Review Issues
|
||||||
|
|
||||||
|
Date: 2026-04-23
|
||||||
|
|
||||||
|
Scope reviewed:
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py`
|
||||||
|
- `backend/app/services/unified_chat_service.py`
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx`
|
||||||
|
- `frontend/src/components/pilot/ProposalBanner.tsx`
|
||||||
|
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
|
||||||
|
|
||||||
|
## 1. Outcome writes do not invalidate Resolve/Escalate preview cache
|
||||||
|
|
||||||
|
Severity: High
|
||||||
|
|
||||||
|
`PATCH /suggested-fixes/{fix_id}/outcome` updates the fix row but does not bump
|
||||||
|
`ai_sessions.state_version`. Even after adding that bump, the preview input
|
||||||
|
bundle also needs to include the fix outcome fields; otherwise a regenerated
|
||||||
|
preview still cannot distinguish proposed, partially applied, failed, or
|
||||||
|
successful fixes.
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py:226`
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py:146`
|
||||||
|
- `backend/app/services/resolution_note_generator.py:13`
|
||||||
|
- `backend/app/services/escalation_package_generator.py:14`
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- Resolve and Escalate previews are cached by `(session_id, state_version)`.
|
||||||
|
- The decision endpoint already bumps `state_version`.
|
||||||
|
- The new outcome endpoint does not.
|
||||||
|
- A user can record `applied_success` / `applied_failed` / `applied_partial`
|
||||||
|
and still see markdown generated from the pre-outcome session state.
|
||||||
|
- The preview generators currently pass only the active fix title,
|
||||||
|
confidence, description, and user decision into the LLM bundle. They do not
|
||||||
|
pass `status`, `applied_at`, `verified_at`, `partial_notes`, or
|
||||||
|
`failure_reason`.
|
||||||
|
- Therefore a cache miss alone is not enough: the generated markdown may still
|
||||||
|
describe the fix as merely proposed because the outcome is absent from the
|
||||||
|
prompt input.
|
||||||
|
|
||||||
|
Recommended fix:
|
||||||
|
- Bump `AISession.state_version` inside the outcome endpoint transaction.
|
||||||
|
- Include suggested-fix outcome state in both preview bundles:
|
||||||
|
- `status`
|
||||||
|
- `applied_at`
|
||||||
|
- `verified_at`
|
||||||
|
- `partial_notes`
|
||||||
|
- `failure_reason`
|
||||||
|
- Update the resolution-note prompt expectations so `applied_success` produces
|
||||||
|
closure language, `applied_failed` states that the proposed fix did not
|
||||||
|
resolve the issue, and `applied_partial` includes the engineer's partial
|
||||||
|
notes.
|
||||||
|
- Update the escalation-package prompt expectations so failed/partial outcomes
|
||||||
|
appear under "What we've tried" and inform "Suggested next steps."
|
||||||
|
- Add a test proving a preview generated before an outcome change is
|
||||||
|
invalidated after the outcome patch and that the regenerated preview input
|
||||||
|
includes the recorded outcome.
|
||||||
|
|
||||||
|
## 2. "Apply" is not persisted, so Verifying state is lost on reload/reselect
|
||||||
|
|
||||||
|
Severity: High
|
||||||
|
|
||||||
|
Phase 8 introduces a Verifying lifecycle in the UI, but clicking Apply only
|
||||||
|
sets local React state.
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:142`
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:516`
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py:276`
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- `bannerApplied` is a client-side-only flag.
|
||||||
|
- `handleApplyFix()` opens the script panel and flips local state, but does not
|
||||||
|
persist anything.
|
||||||
|
- `applied_at` is only stamped later when an outcome is patched.
|
||||||
|
- After refresh, chat reselect, or multi-tab use, a fix that had entered
|
||||||
|
Verifying falls back to `proposed`.
|
||||||
|
- Nudge timing, resolve auto-success, and escalate interception therefore do
|
||||||
|
not survive normal session resume.
|
||||||
|
|
||||||
|
Recommended fix:
|
||||||
|
- Persist "apply started" as part of the fix lifecycle.
|
||||||
|
- Either add an explicit backend transition for apply/start-verifying, or
|
||||||
|
persist `applied_at` when Apply is clicked.
|
||||||
|
- Add a test or browser regression check covering refresh/reselect continuity.
|
||||||
|
|
||||||
|
## 3. Rejecting an AI outcome proposal is only local and will reappear
|
||||||
|
|
||||||
|
Severity: Medium
|
||||||
|
|
||||||
|
Rejecting the AI-confirming banner clears `ai_outcome_proposal` only in local
|
||||||
|
component state.
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:571`
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:431`
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- `handleRejectAIProposal()` only updates local `activeFix`.
|
||||||
|
- The server-side `ai_outcome_proposal` remains unchanged.
|
||||||
|
- The proposal comes back on the next `refreshSessionDerived()` call, which
|
||||||
|
happens after sends, task submissions, and chat selection.
|
||||||
|
- "Not yet" is therefore a temporary hide, not a real rejection/correction.
|
||||||
|
|
||||||
|
Recommended fix:
|
||||||
|
- Add a backend way to clear or reject `ai_outcome_proposal`.
|
||||||
|
- Make the reject action persist so the banner does not immediately re-arm on
|
||||||
|
the next refetch.
|
||||||
|
|
||||||
|
## 4. Pre-existing failing decision test
|
||||||
|
|
||||||
|
Severity: Low (test gap, no runtime regression)
|
||||||
|
|
||||||
|
`tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
|
||||||
|
was authored in Phase 3 (`66e5920`) when the `decision` endpoint had no
|
||||||
|
validation on `ai_drafted_script`. Phase 5 (`fa61376`) added a 400 guard:
|
||||||
|
when the decision is `one_off`, `draft_template`, or `build_template` and the
|
||||||
|
fix has no `ai_drafted_script` (and the caller provides no `edited_script` in
|
||||||
|
the request body), the endpoint returns 400 with the message "Suggested fix has
|
||||||
|
no ai_drafted_script — use /api/v1/scripts/generate for template-matched
|
||||||
|
fixes."
|
||||||
|
|
||||||
|
The test creates a fix without an `ai_drafted_script` and posts
|
||||||
|
`{"decision": "draft_template"}` naked, so the guard fires and returns 400. The
|
||||||
|
test still asserts 200. This was already broken before Phase 8 began — commit
|
||||||
|
`cdd8bb0` (first Phase 8 commit) is 8 commits after `fa61376`.
|
||||||
|
|
||||||
|
Root cause: test was never updated to match the Phase 5 contract change.
|
||||||
|
|
||||||
|
Recommended fix for the next branch:
|
||||||
|
- Option A (minimal): supply `ai_drafted_script="echo hello"` when creating the
|
||||||
|
fix fixture, or add `edited_script` to the POST body. Validates the happy path
|
||||||
|
for `draft_template` with a real drafted body.
|
||||||
|
- Option B (comprehensive): add a separate test case asserting the 400 when
|
||||||
|
`ai_drafted_script` is null and no `edited_script` is provided, then fix the
|
||||||
|
existing test as in Option A. The 400-guard already has coverage in the
|
||||||
|
Phase 5 test file; the main gap is just the missing fixture update here.
|
||||||
|
|
||||||
|
No Phase 8 code change required — this is a test-fixture gap from Phase 3/5
|
||||||
|
drift, not a regression introduced in this branch.
|
||||||
|
|
||||||
|
## Test Context
|
||||||
|
|
||||||
|
Relevant backend suites were run serially from `backend/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py tests/test_session_suggested_fixes_api.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed result:
|
||||||
|
- `28 passed`
|
||||||
|
- `1 failed`
|
||||||
|
|
||||||
|
Remaining failure:
|
||||||
|
- `tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- That failing test is in the older decision-path suite and expects
|
||||||
|
`draft_template` to succeed without a drafted script.
|
||||||
|
- The new outcome endpoint tests and marker parser tests passed in the serial
|
||||||
|
run.
|
||||||
|
- The three issues above are based on code inspection and remain valid
|
||||||
|
regardless of that separate failing test.
|
||||||
|
- Full root cause analysis documented in section 4 above.
|
||||||
87
docs/FlowAssist_Migration/Issues/phase-9-review-issues.md
Normal file
87
docs/FlowAssist_Migration/Issues/phase-9-review-issues.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Phase 9 Review Issues
|
||||||
|
|
||||||
|
Date: 2026-04-24
|
||||||
|
|
||||||
|
Scope reviewed:
|
||||||
|
- `backend/app/api/endpoints/script_builder.py`
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py`
|
||||||
|
- `backend/app/services/script_builder_service.py`
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx`
|
||||||
|
- `frontend/src/components/pilot/ScriptBuilderTab.tsx`
|
||||||
|
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
|
||||||
|
|
||||||
|
## 1. "Applied partially" from the escalation intercept cannot persist
|
||||||
|
|
||||||
|
Severity: High
|
||||||
|
|
||||||
|
The escalation intercept offers an "applied partially" choice, but the frontend
|
||||||
|
sends `applied_partial` without notes. The backend requires notes for that
|
||||||
|
outcome and returns 400. The frontend catches the error silently and still opens
|
||||||
|
the conclude modal, so the user can believe the partial outcome was recorded
|
||||||
|
when it was not.
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:659`
|
||||||
|
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx:56`
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py:316`
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- `handleInterceptChoice()` maps the partial button directly to
|
||||||
|
`patchOutcome(..., "applied_partial")`.
|
||||||
|
- The call does not provide `notes`.
|
||||||
|
- `PATCH /suggested-fixes/{fix_id}/outcome` rejects `applied_partial` without
|
||||||
|
notes.
|
||||||
|
- The catch block is silent and the UI continues into the conclude flow.
|
||||||
|
- The recorded fix status therefore remains unchanged while the user sees a
|
||||||
|
flow that implies the partial outcome was accepted.
|
||||||
|
|
||||||
|
Recommended fix:
|
||||||
|
- Prompt for partial notes before calling `patchOutcome()` with
|
||||||
|
`applied_partial`.
|
||||||
|
- Do not proceed to the conclude modal if the partial outcome write fails.
|
||||||
|
- Consider hiding or disabling the partial option when it is not applicable, or
|
||||||
|
pass the current fix status into `EscalateInterceptDialog` so it can render
|
||||||
|
valid choices only.
|
||||||
|
- Add a regression test covering the partial escalation-intercept path.
|
||||||
|
|
||||||
|
## 2. Script Builder can attach stale script state to a newer active fix
|
||||||
|
|
||||||
|
Severity: Medium/High
|
||||||
|
|
||||||
|
`ScriptBuilderTab` keeps local builder state across active-fix changes within
|
||||||
|
the same pilot chat. If a new active fix supersedes the previous one while the
|
||||||
|
tab remains mounted, old messages, `latestScript`, or editor text can remain in
|
||||||
|
memory while submission uses the new `fix.id`.
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
- `frontend/src/components/pilot/ScriptBuilderTab.tsx:55`
|
||||||
|
- `frontend/src/components/pilot/ScriptBuilderTab.tsx:78`
|
||||||
|
- `frontend/src/components/pilot/ScriptBuilderTab.tsx:150`
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:399`
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx:1630`
|
||||||
|
|
||||||
|
Why this matters:
|
||||||
|
- `ScriptBuilderTab` initializes `editorBuffer`, messages, and latest script
|
||||||
|
from props and builder-session data.
|
||||||
|
- The create/resume effect depends on `pilotSessionId`, not `fix.id`.
|
||||||
|
- `AssistantChatPage` detects active-fix changes but only closes the script
|
||||||
|
panel.
|
||||||
|
- The rendered `ScriptBuilderTab` is not keyed by active fix id.
|
||||||
|
- Submitting a stale builder draft calls the script patch endpoint with the
|
||||||
|
current `fix.id`, so an older script can be attached to a newer fix.
|
||||||
|
|
||||||
|
Recommended fix:
|
||||||
|
- Reset Script Builder local state when `activeFix.id` changes.
|
||||||
|
- Key the rendered `ScriptBuilderTab` by `activeFix.id` if the intended UX is a
|
||||||
|
fresh builder surface per fix.
|
||||||
|
- If inline builder conversations are intended to resume per fix, extend the
|
||||||
|
backend idempotency model to include the fix id instead of only
|
||||||
|
`(user_id, ai_session_id)`.
|
||||||
|
- Add a frontend regression test for an active fix changing while the Script
|
||||||
|
Builder tab is mounted.
|
||||||
|
|
||||||
|
## Review Context
|
||||||
|
|
||||||
|
This review was based on code inspection of the latest committed Phase 9
|
||||||
|
implementation. No tracked working-tree diffs were present at review time.
|
||||||
|
|
||||||
679
docs/FlowAssist_Migration/mockups/05-resolve-cta-merge.html
Normal file
679
docs/FlowAssist_Migration/mockups/05-resolve-cta-merge.html
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FlowPilot — Suggested Fix → Resolve CTA merge (Option A)</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-sidebar: #0e1016;
|
||||||
|
--bg-page: #16181f;
|
||||||
|
--bg-card: #1e2028;
|
||||||
|
--bg-elevated: #2a2d38;
|
||||||
|
--border-default: rgba(148, 163, 184, 0.12);
|
||||||
|
--border-hover: rgba(148, 163, 184, 0.22);
|
||||||
|
--text-heading: #f1f5f9;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-muted-foreground: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
--accent-dim: rgba(96, 165, 250, 0.10);
|
||||||
|
--accent-border: rgba(96, 165, 250, 0.30);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-dim: rgba(251, 191, 36, 0.10);
|
||||||
|
--warning-border: rgba(251, 191, 36, 0.28);
|
||||||
|
--success: #34d399;
|
||||||
|
--success-dim: rgba(52, 211, 153, 0.10);
|
||||||
|
--success-border: rgba(52, 211, 153, 0.28);
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.page-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 840px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Column scaffold (pretending to be the task-lane rail) ----- */
|
||||||
|
.col {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 760px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.col-head {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
.col-head-label {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.col-head-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.col-head-tag.today { color: var(--text-muted-foreground); }
|
||||||
|
.col-head-tag.opt-a { color: var(--accent); }
|
||||||
|
.col-head-tag.opt-a-disabled { color: var(--warning); }
|
||||||
|
|
||||||
|
.lane-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Section labels (match current component styling) ----- */
|
||||||
|
.section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
padding: 0 2px 8px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.dot-accent { background: var(--accent); }
|
||||||
|
.dot-warning { background: var(--warning); }
|
||||||
|
.dot-success { background: var(--success); }
|
||||||
|
.section-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
.conf-high { color: var(--success); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
|
||||||
|
|
||||||
|
/* ----- What-we-know facts ----- */
|
||||||
|
.fact {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.fact + .fact { margin-top: 8px; }
|
||||||
|
.fact-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.fact-body { min-width: 0; flex: 1; }
|
||||||
|
.fact-title {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-heading);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.fact-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Suggested fix card (today only) ----- */
|
||||||
|
.fix-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
background: var(--warning-dim);
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.fix-spark {
|
||||||
|
color: var(--warning);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.fix-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-heading);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.fix-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.fix-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.fix-x {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Action bar at bottom ----- */
|
||||||
|
.action-bar {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s ease, background-color 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-resolve-today {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0d14;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-resolve-today:hover { background: #7ab4fb; color: #0a0d14; }
|
||||||
|
|
||||||
|
/* Option A — Resolve w/ embedded fix */
|
||||||
|
.btn-resolve-merged {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0d14;
|
||||||
|
border-color: transparent;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 52px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.btn-resolve-merged:hover { background: #7ab4fb; color: #0a0d14; }
|
||||||
|
.btn-resolve-merged .rc-leading {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(10, 13, 20, 0.72);
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
}
|
||||||
|
.btn-resolve-merged .rc-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0a0d14;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.btn-resolve-merged .rc-body {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.btn-resolve-merged .rc-conf {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(10, 13, 20, 0.14);
|
||||||
|
color: #0a0d14;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-resolve-merged .rc-chevron {
|
||||||
|
color: rgba(10, 13, 20, 0.55);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled (no proposal yet) */
|
||||||
|
.btn-resolve-disabled {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
border: 1px dashed var(--border-hover);
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-height: 52px;
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.btn-resolve-disabled .rc-leading {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
}
|
||||||
|
.btn-resolve-disabled .rc-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Escalate / overflow */
|
||||||
|
.btn-escalate {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
}
|
||||||
|
.btn-escalate:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* tiny spinner dot for the waiting state */
|
||||||
|
.pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--warning);
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5);
|
||||||
|
animation: pulse 1.6s infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation callouts beneath the columns */
|
||||||
|
.callout {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.callout strong { color: var(--text-heading); font-weight: 600; }
|
||||||
|
.callout.note-accent { border-left: 3px solid var(--accent); }
|
||||||
|
.callout.note-warning { border-left: 3px solid var(--warning); }
|
||||||
|
.callout.note-muted { border-left: 3px solid var(--border-hover); }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px 32px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.legend h4 {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-heading);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.legend li { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* subtle faux scrollbar hint */
|
||||||
|
.lane-body::-webkit-scrollbar { width: 6px; }
|
||||||
|
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">Option A — Suggested Fix merges into the Resolve CTA</div>
|
||||||
|
<div class="page-sub">
|
||||||
|
Three versions of the same task lane. <strong style="color:var(--text-primary)">Today</strong> keeps Suggested Fix as a separate card that gets pushed down by a long facts list. <strong style="color:var(--text-primary)">Option A (armed)</strong> deletes the card — the Resolve button at the bottom becomes the proposal. <strong style="color:var(--text-primary)">Option A (waiting)</strong> is what the same bar looks like before the AI emits a proposal.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
|
||||||
|
<!-- ============== COLUMN 1: TODAY ============== -->
|
||||||
|
<div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<div class="col-head-label">Today</div>
|
||||||
|
<div class="col-head-tag today">Baseline</div>
|
||||||
|
</div>
|
||||||
|
<div class="lane-body">
|
||||||
|
|
||||||
|
<!-- What we know -->
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="dot dot-accent"></span>
|
||||||
|
What we know
|
||||||
|
<span class="section-meta">· 5 facts</span>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||||
|
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
|
||||||
|
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
|
||||||
|
<div class="fact-meta">promoted 14:11 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
|
||||||
|
<div class="fact-meta">promoted 14:14 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
|
||||||
|
<div class="fact-meta">promoted 14:18 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Suggested Fix card (this is the thing that gets buried) -->
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="dot dot-warning"></span>
|
||||||
|
Suggested fix
|
||||||
|
<span class="section-meta">·</span>
|
||||||
|
<span class="conf-high">94% confidence</span>
|
||||||
|
</div>
|
||||||
|
<div class="fix-card">
|
||||||
|
<svg class="fix-spark" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
<div style="min-width:0;flex:1">
|
||||||
|
<div class="fix-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||||
|
<div class="fix-desc">Remove stale entries from Credential Manager referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.</div>
|
||||||
|
<div class="fix-hint">✓ Matches an existing Script Library template — click to use</div>
|
||||||
|
</div>
|
||||||
|
<button class="fix-x" aria-label="Dismiss">✕</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn btn-escalate btn-secondary">Escalate</button>
|
||||||
|
<button class="btn btn-resolve-today">Resolve</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout note-muted">
|
||||||
|
<strong>Baseline problem.</strong> The Suggested Fix card sits after What-we-know. With 5+ facts (common by mid-session) it's below the fold. The generic <em>Resolve</em> button at the bottom doesn't surface what would be resolved, so the engineer has to scroll up, read the card, then scroll back down to act.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== COLUMN 2: OPTION A — ARMED ============== -->
|
||||||
|
<div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<div class="col-head-label">Option A — armed</div>
|
||||||
|
<div class="col-head-tag opt-a">Proposal ready</div>
|
||||||
|
</div>
|
||||||
|
<div class="lane-body">
|
||||||
|
|
||||||
|
<!-- Same facts, but no Suggested Fix card -->
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="dot dot-accent"></span>
|
||||||
|
What we know
|
||||||
|
<span class="section-meta">· 5 facts</span>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||||
|
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
|
||||||
|
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
|
||||||
|
<div class="fact-meta">promoted 14:11 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
|
||||||
|
<div class="fact-meta">promoted 14:14 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
|
||||||
|
<div class="fact-meta">promoted 14:18 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- NO Suggested Fix card here — it lives on the button -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn btn-escalate btn-secondary">Escalate</button>
|
||||||
|
<button class="btn btn-resolve-merged" aria-label="Resolve with: Clear cached credentials + rebuild Outlook profile (94% confidence)">
|
||||||
|
<div class="rc-body">
|
||||||
|
<div class="rc-leading">Resolve with</div>
|
||||||
|
<div class="rc-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||||
|
</div>
|
||||||
|
<span class="rc-conf">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
94%
|
||||||
|
</span>
|
||||||
|
<svg class="rc-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout note-accent">
|
||||||
|
<strong>What changes.</strong> The Suggested Fix card is gone. Its content moved onto the Resolve button, which is always in view. One click = accept the fix + open the existing <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">ResolutionNotePreview</code> popover pre-filled. No card-then-button two-step.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== COLUMN 3: OPTION A — WAITING ============== -->
|
||||||
|
<div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-head">
|
||||||
|
<div class="col-head-label">Option A — waiting</div>
|
||||||
|
<div class="col-head-tag opt-a-disabled">No proposal yet</div>
|
||||||
|
</div>
|
||||||
|
<div class="lane-body">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="dot dot-accent"></span>
|
||||||
|
What we know
|
||||||
|
<span class="section-meta">· 2 facts</span>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||||
|
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<span class="fact-icon"></span>
|
||||||
|
<div class="fact-body">
|
||||||
|
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant.</div>
|
||||||
|
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn btn-escalate btn-secondary">Escalate</button>
|
||||||
|
<button class="btn btn-resolve-disabled" disabled aria-label="Resolve (waiting for AI proposal)">
|
||||||
|
<span class="pulse" aria-hidden="true"></span>
|
||||||
|
<div class="rc-body">
|
||||||
|
<div class="rc-leading">Resolve</div>
|
||||||
|
<div class="rc-title">Waiting for proposal…</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout note-warning">
|
||||||
|
<strong>Before confidence threshold.</strong> Same slot, disabled state. Amber pulse signals the AI is still reasoning. Below threshold or no proposal yet → same visual — the engineer can still use <em>Escalate</em> at any time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== LEGEND / TRADE-OFFS ============== -->
|
||||||
|
<div class="legend">
|
||||||
|
<div>
|
||||||
|
<h4>Why this helps discoverability</h4>
|
||||||
|
<ul style="padding-left:18px;list-style:disc">
|
||||||
|
<li>Proposal is in the place the engineer looks to <em>act</em>, not in the scrolling lane above.</li>
|
||||||
|
<li>Resolve bar is already sticky at the bottom — no new sticky patterns needed (preserves the <code style="font-family:'JetBrains Mono',monospace;font-size:11px">8879f96</code> fix).</li>
|
||||||
|
<li>Accepting a fix and resolving the session collapse into one click instead of two.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>What you give up</h4>
|
||||||
|
<ul style="padding-left:18px;list-style:disc">
|
||||||
|
<li>No space for secondary info on the button (reasoning, alternative fixes). Would need an expand/chevron or hover tooltip.</li>
|
||||||
|
<li>No standalone "dismiss this fix" affordance — need to decide where dismiss/reject lives (chevron menu? secondary button?).</li>
|
||||||
|
<li>If the AI proposes multiple candidates, only the top one fits the button. Need a "▾ 2 other candidates" menu.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
849
docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
Normal file
849
docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
Normal file
@@ -0,0 +1,849 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FlowPilot — Suggested Fix as slide-up composer banner</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-sidebar: #0e1016;
|
||||||
|
--bg-page: #16181f;
|
||||||
|
--bg-card: #1e2028;
|
||||||
|
--bg-elevated: #2a2d38;
|
||||||
|
--border-default: rgba(148, 163, 184, 0.12);
|
||||||
|
--border-hover: rgba(148, 163, 184, 0.22);
|
||||||
|
--text-heading: #f1f5f9;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-muted-foreground: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
--accent-dim: rgba(96, 165, 250, 0.10);
|
||||||
|
--accent-border: rgba(96, 165, 250, 0.30);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-dim: rgba(251, 191, 36, 0.10);
|
||||||
|
--warning-dim-strong: rgba(251, 191, 36, 0.16);
|
||||||
|
--warning-border: rgba(251, 191, 36, 0.32);
|
||||||
|
--success: #34d399;
|
||||||
|
--success-dim: rgba(52, 211, 153, 0.10);
|
||||||
|
--success-border: rgba(52, 211, 153, 0.28);
|
||||||
|
--danger: #f87171;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header { margin-bottom: 24px; }
|
||||||
|
.page-title {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.page-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 980px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================== Main frame =================== */
|
||||||
|
.frame {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
height: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Chat area ------ */
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-page);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chat-head {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.chat-head-title {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.chat-head-sub {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.chat-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 28px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.msg.user { align-self: flex-end; }
|
||||||
|
.msg-av {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.msg.user .msg-av {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
}
|
||||||
|
.msg.ai .msg-av {
|
||||||
|
background: var(--warning-dim);
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
}
|
||||||
|
.msg-body {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.msg.user .msg-body {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.msg-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Composer area (sticky bottom of chat) ------ */
|
||||||
|
.composer-wrap {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-page);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Slide-up banner ------ */
|
||||||
|
.proposal-banner {
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid var(--warning-border);
|
||||||
|
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
|
||||||
|
padding: 12px 20px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
animation: slideUp 320ms cubic-bezier(.22, .9, .28, 1) both;
|
||||||
|
}
|
||||||
|
.proposal-banner::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(14px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-icon {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--warning-dim-strong);
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--warning);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.proposal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.proposal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--warning);
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
}
|
||||||
|
.proposal-head .pill {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(251, 191, 36, 0.20);
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-family: 'IBM Plex Sans', sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.proposal-title {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-heading);
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.proposal-desc {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.proposal-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--success);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
.btn-apply {
|
||||||
|
background: var(--warning);
|
||||||
|
color: #1a1200;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}
|
||||||
|
.btn-apply:hover { background: #ffce4f; color: #1a1200; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
border-color: transparent;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Composer ------ */
|
||||||
|
.composer {
|
||||||
|
padding: 14px 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.composer-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.composer-send {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0d14;
|
||||||
|
border: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------ Task lane (right rail) ------ */
|
||||||
|
.lane {
|
||||||
|
border-left: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.lane-head {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.lane-head-label {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.lane-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
padding: 0 2px 8px;
|
||||||
|
}
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot-accent { background: var(--accent); }
|
||||||
|
.dot-muted { background: var(--text-muted); }
|
||||||
|
.section-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.fact + .fact { margin-top: 8px; }
|
||||||
|
.fact-title {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-heading);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.fact-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismissed-pill {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px dashed var(--border-hover);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.dismissed-pill:hover { border-color: var(--warning-border); color: var(--warning); }
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
|
||||||
|
.btn-resolve {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0d14;
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
|
||||||
|
|
||||||
|
/* =================== Callouts =================== */
|
||||||
|
.callout {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.55;
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
}
|
||||||
|
.callout strong { color: var(--text-heading); font-weight: 600; }
|
||||||
|
|
||||||
|
/* =================== State detail row =================== */
|
||||||
|
.states-title {
|
||||||
|
margin-top: 48px;
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.states-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.states {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.state-label {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
.state-body {
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-page);
|
||||||
|
min-height: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.state-mini-chat {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed banner variant */
|
||||||
|
.banner-collapsed {
|
||||||
|
border-top: 1px solid var(--warning-border);
|
||||||
|
background: var(--warning-dim);
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.banner-collapsed::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
.banner-collapsed-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-heading);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.banner-collapsed .pill {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(251, 191, 36, 0.20);
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.banner-collapsed .expand {
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mini composer for the detail states */
|
||||||
|
.mini-composer {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.mini-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mini-send {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0d14;
|
||||||
|
border: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pill in chat stream (replaced state) */
|
||||||
|
.replaced-note {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px dashed var(--border-hover);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* annotation captions under each state */
|
||||||
|
.state-caption {
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.5;
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
.state-caption strong { color: var(--text-heading); font-weight: 600; }
|
||||||
|
|
||||||
|
.lane-body::-webkit-scrollbar { width: 6px; }
|
||||||
|
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||||
|
.chat-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">Option C — Suggested Fix slides up from the chat composer</div>
|
||||||
|
<div class="page-sub">
|
||||||
|
The AI's proposal docks as a persistent banner just above the chat composer — right where the engineer's eyes already are. Apply lives on the banner (warning amber). <em>Resolve</em> stays generic at the bottom of the task lane, so there's no false-resolve risk. The Suggested Fix card is removed from the task lane entirely.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ MAIN: Armed + expanded ============ -->
|
||||||
|
<div class="frame">
|
||||||
|
|
||||||
|
<div class="chat">
|
||||||
|
<div class="chat-head">
|
||||||
|
<div>
|
||||||
|
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
|
||||||
|
<div class="chat-head-sub">ticket #48213 · in progress · 14:22</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-head-actions">
|
||||||
|
<button class="btn btn-ghost">Share update</button>
|
||||||
|
<button class="btn icon-btn" aria-label="More">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-scroll">
|
||||||
|
<div class="msg ai">
|
||||||
|
<div class="msg-av">AI</div>
|
||||||
|
<div>
|
||||||
|
<div class="msg-body">
|
||||||
|
A few things we know line up with a stale-credential pattern rather than an MFA or network issue. Can you confirm whether the user has other Microsoft 365 services (Teams, SharePoint) working on the same workstation?
|
||||||
|
</div>
|
||||||
|
<div class="msg-meta">14:16</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg user">
|
||||||
|
<div>
|
||||||
|
<div class="msg-body">Teams works fine. SharePoint in browser is fine too. It's just Outlook.</div>
|
||||||
|
<div class="msg-meta">14:17</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg-av">ME</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg ai">
|
||||||
|
<div class="msg-av">AI</div>
|
||||||
|
<div>
|
||||||
|
<div class="msg-body">
|
||||||
|
That narrows it to the Outlook profile specifically. Given Credential Manager still has entries pointing at the prior tenant, the cleanest path is to clear those entries and rebuild the local Outlook profile — the client will re-auth against the current tenant from scratch.
|
||||||
|
</div>
|
||||||
|
<div class="msg-meta">14:22</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Slide-up banner ============ -->
|
||||||
|
<div class="composer-wrap">
|
||||||
|
|
||||||
|
<div class="proposal-banner" role="region" aria-label="AI proposed fix">
|
||||||
|
<div class="proposal-icon">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-body">
|
||||||
|
<div class="proposal-head">
|
||||||
|
<span>Suggested Fix</span>
|
||||||
|
<span class="pill">94% confidence</span>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||||
|
<div class="proposal-desc">
|
||||||
|
Remove stale Credential Manager entries referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.
|
||||||
|
</div>
|
||||||
|
<div class="proposal-hint">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
Matches an existing Script Library template — one-click apply
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-actions">
|
||||||
|
<button class="btn btn-ghost" aria-label="Collapse banner">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" aria-label="Dismiss fix">Dismiss</button>
|
||||||
|
<button class="btn btn-apply">
|
||||||
|
Apply fix
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<div class="composer-input">Ask a follow-up, paste an error, drop a screenshot…</div>
|
||||||
|
<button class="composer-send" aria-label="Send">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ Task lane (no Suggested Fix card) ============ -->
|
||||||
|
<div class="lane">
|
||||||
|
<div class="lane-head">
|
||||||
|
<div class="lane-head-label">Task lane</div>
|
||||||
|
</div>
|
||||||
|
<div class="lane-body">
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="dot dot-accent"></span>
|
||||||
|
What we know
|
||||||
|
<span class="section-meta">· 5 facts</span>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||||
|
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
|
||||||
|
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">MFA prompt appears but fails silently — no authenticator notification.</div>
|
||||||
|
<div class="fact-meta">promoted 14:11 · from chat</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">Other devices under same account authenticate successfully.</div>
|
||||||
|
<div class="fact-meta">promoted 14:14 · from chat</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
|
||||||
|
<div class="fact-meta">promoted 14:22 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<button class="btn btn-escalate">Escalate</button>
|
||||||
|
<button class="btn btn-resolve">Resolve</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<strong>How it reads.</strong> Proposal arrives with a 320ms slide-up from below the composer, docks as a persistent banner until applied, dismissed, or replaced. Apply is amber (not accent-blue) so it visually belongs to the proposal, not the chat send button. Resolve in the task lane stays generic — there's no false-resolve risk because the two actions are spatially and visually separate.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ State detail row ============ -->
|
||||||
|
<div class="states-title">Banner states</div>
|
||||||
|
<div class="states-sub">What the same region looks like in the other three states — collapsed to save chat space, after the engineer dismisses it, and when a new proposal replaces an existing one.</div>
|
||||||
|
|
||||||
|
<div class="states">
|
||||||
|
|
||||||
|
<!-- STATE 1: Collapsed -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">Collapsed (saves chat space)</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="state-mini-chat">…earlier messages…</div>
|
||||||
|
<div class="banner-collapsed">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
<span class="banner-collapsed-title">Clear cached credentials + rebuild Outlook profile</span>
|
||||||
|
<span class="pill">94%</span>
|
||||||
|
<span class="expand">▸ expand</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-composer">
|
||||||
|
<div class="mini-input">Type a message…</div>
|
||||||
|
<button class="mini-send">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>~28px strip.</strong> Auto-collapses after 30s of no interaction, or when the engineer clicks the chevron. Title + confidence still visible. Click strip → expands. Apply still reachable via the expanded state.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATE 2: Dismissed (pill in lane) -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">Dismissed — parked in the task lane</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="state-mini-chat">chat unobstructed · banner gone</div>
|
||||||
|
<div class="mini-composer">
|
||||||
|
<div class="mini-input">Type a message…</div>
|
||||||
|
<button class="mini-send">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 12px 14px; background: var(--bg-sidebar); border-top: 1px solid var(--border-default);">
|
||||||
|
<div class="section-label" style="padding-bottom: 6px">
|
||||||
|
<span class="dot dot-muted"></span>
|
||||||
|
Dismissed proposals
|
||||||
|
<span class="section-meta">· 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="dismissed-pill">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
<span style="flex:1;color:var(--text-heading)">Clear cached credentials…</span>
|
||||||
|
<span style="color:var(--text-muted)">restore ↺</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>Recoverable, out of the way.</strong> Dismissing the banner parks the proposal as a pill in the task lane. Clicking restore → banner slides back in. Prevents accidental loss.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STATE 3: Replaced -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">Replaced — new proposal overrides old</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;justify-content:flex-end;">
|
||||||
|
<span class="replaced-note">previous: "Rebuild Outlook profile" — didn't resolve, new proposal below</span>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-banner" style="padding:10px 14px;gap:10px;">
|
||||||
|
<div class="proposal-icon" style="width:22px;height:22px;border-radius:6px">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-body">
|
||||||
|
<div class="proposal-head" style="font-size:9px">
|
||||||
|
<span>New suggested fix</span>
|
||||||
|
<span class="pill" style="font-size:9.5px;padding:1px 6px">78%</span>
|
||||||
|
</div>
|
||||||
|
<div class="proposal-title" style="font-size:12.5px">Reset Autodiscover registry entries for this user</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-apply" style="padding:6px 10px;font-size:11.5px">Apply</button>
|
||||||
|
</div>
|
||||||
|
<div class="mini-composer">
|
||||||
|
<div class="mini-input">Type a message…</div>
|
||||||
|
<button class="mini-send">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>Old proposal cross-fades out, new one slides in.</strong> 200ms cross-fade, same slot. A tiny footnote in chat ("previous didn't resolve") preserves the audit trail without re-stacking banners.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
805
docs/FlowAssist_Migration/mockups/07-verify-states.html
Normal file
805
docs/FlowAssist_Migration/mockups/07-verify-states.html
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>FlowPilot — Post-apply outcome states</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-sidebar: #0e1016;
|
||||||
|
--bg-page: #16181f;
|
||||||
|
--bg-card: #1e2028;
|
||||||
|
--bg-elevated: #2a2d38;
|
||||||
|
--border-default: rgba(148, 163, 184, 0.12);
|
||||||
|
--border-hover: rgba(148, 163, 184, 0.22);
|
||||||
|
--text-heading: #f1f5f9;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-muted-foreground: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--accent: #60a5fa;
|
||||||
|
--accent-dim: rgba(96, 165, 250, 0.10);
|
||||||
|
--accent-dim-strong: rgba(96, 165, 250, 0.16);
|
||||||
|
--accent-border: rgba(96, 165, 250, 0.30);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-dim: rgba(251, 191, 36, 0.10);
|
||||||
|
--warning-dim-strong: rgba(251, 191, 36, 0.16);
|
||||||
|
--warning-border: rgba(251, 191, 36, 0.32);
|
||||||
|
--success: #34d399;
|
||||||
|
--success-dim: rgba(52, 211, 153, 0.10);
|
||||||
|
--success-dim-strong: rgba(52, 211, 153, 0.16);
|
||||||
|
--success-border: rgba(52, 211, 153, 0.30);
|
||||||
|
--info: #67e8f9;
|
||||||
|
--info-dim: rgba(103, 232, 249, 0.10);
|
||||||
|
--info-dim-strong: rgba(103, 232, 249, 0.16);
|
||||||
|
--info-border: rgba(103, 232, 249, 0.30);
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-dim: rgba(248, 113, 113, 0.10);
|
||||||
|
--danger-dim-strong: rgba(248, 113, 113, 0.16);
|
||||||
|
--danger-border: rgba(248, 113, 113, 0.30);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 72px;
|
||||||
|
}
|
||||||
|
.page-header { margin-bottom: 24px; }
|
||||||
|
.page-title {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--text-heading);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.page-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 1020px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Shared button styles ====== */
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px; height: 30px; padding: 0;
|
||||||
|
background: transparent; border: 1px solid transparent;
|
||||||
|
color: var(--text-muted-foreground);
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: rgba(148, 163, 184, 0.08); color: var(--text-primary); }
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--success); color: #0a1a12; border-color: transparent; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-success:hover { background: #55e0af; color: #0a1a12; }
|
||||||
|
.btn-danger-outline {
|
||||||
|
background: transparent; color: var(--danger); border-color: var(--danger-border);
|
||||||
|
}
|
||||||
|
.btn-danger-outline:hover { background: var(--danger-dim); color: var(--danger); border-color: var(--danger); }
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger); color: #180808; border-color: transparent; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #fa8a8a; color: #180808; }
|
||||||
|
|
||||||
|
/* ====== Frame ====== */
|
||||||
|
.frame {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
height: 760px;
|
||||||
|
}
|
||||||
|
.chat {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-page);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chat-head {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
}
|
||||||
|
.chat-head-title {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600; font-size: 14px; color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.chat-head-sub {
|
||||||
|
font-size: 11.5px; color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 24px 28px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.msg { max-width: 640px; display: flex; gap: 10px; align-items: flex-start; }
|
||||||
|
.msg.user { align-self: flex-end; }
|
||||||
|
.msg-av {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
flex-shrink: 0; font-size: 11px; font-weight: 600;
|
||||||
|
display: flex; align-items: center; justify-content: center; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.msg.user .msg-av { background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent-border); }
|
||||||
|
.msg.ai .msg-av { background: var(--warning-dim); color: var(--warning); border: 1px solid var(--warning-border); }
|
||||||
|
.msg.system .msg-av { background: rgba(148,163,184,0.08); color: var(--text-muted); border: 1px solid var(--border-default); }
|
||||||
|
.msg-body {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px; padding: 10px 13px; font-size: 13px; color: var(--text-primary);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.msg.user .msg-body { background: var(--accent-dim); border-color: var(--accent-border); color: var(--text-heading); }
|
||||||
|
.msg.system .msg-body { background: transparent; border-style: dashed; color: var(--text-muted); font-size: 12px; font-style: italic; }
|
||||||
|
.msg-meta {
|
||||||
|
margin-top: 4px; font-size: 10.5px; color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-wrap { border-top: 1px solid var(--border-default); background: var(--bg-page); position: relative; }
|
||||||
|
.composer { padding: 14px 20px 16px; display: flex; align-items: flex-end; gap: 10px; }
|
||||||
|
.composer-input {
|
||||||
|
flex: 1; min-height: 44px; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default); border-radius: 10px;
|
||||||
|
padding: 10px 14px; color: var(--text-muted-foreground);
|
||||||
|
font-size: 13px; line-height: 1.4;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.composer-send {
|
||||||
|
width: 44px; height: 44px; border-radius: 10px;
|
||||||
|
background: var(--accent); color: #0a0d14; border: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Banner generic ====== */
|
||||||
|
.banner {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 20px 14px;
|
||||||
|
display: flex; gap: 14px; align-items: flex-start;
|
||||||
|
border-top-width: 1px; border-top-style: solid;
|
||||||
|
animation: fadeIn 260ms ease-out both;
|
||||||
|
}
|
||||||
|
.banner::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
width: 28px; height: 28px; border-radius: 7px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.banner-body { flex: 1; min-width: 0; }
|
||||||
|
.banner-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase; font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
}
|
||||||
|
.banner-title {
|
||||||
|
margin-top: 3px; font-size: 14px; font-weight: 600;
|
||||||
|
color: var(--text-heading); line-height: 1.35; letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.banner-note {
|
||||||
|
margin-top: 3px; font-size: 12.5px; color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.banner-actions {
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
flex-shrink: 0; padding-top: 2px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
padding: 2px 7px; border-radius: 999px;
|
||||||
|
font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verifying — amber pulse, mirrors the proposed color but with pulse */
|
||||||
|
.banner-verify {
|
||||||
|
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
|
||||||
|
border-top-color: var(--warning-border);
|
||||||
|
}
|
||||||
|
.banner-verify::before { background: var(--warning); }
|
||||||
|
.banner-verify .banner-icon {
|
||||||
|
background: var(--warning-dim-strong); border: 1px solid var(--warning-border); color: var(--warning);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.banner-verify .banner-icon::after {
|
||||||
|
content: ''; position: absolute; inset: -3px; border-radius: 9px;
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45);
|
||||||
|
animation: pulseAmber 1.6s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulseAmber {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
|
||||||
|
70% { box-shadow: 0 0 0 10px rgba(251, 191, 36, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
|
||||||
|
}
|
||||||
|
.banner-verify .banner-head { color: var(--warning); }
|
||||||
|
.banner-verify .pill { background: rgba(251, 191, 36, 0.20); color: var(--warning); }
|
||||||
|
|
||||||
|
/* Partial — muted info/cyan to communicate "parked, outcome unknown" */
|
||||||
|
.banner-partial {
|
||||||
|
background: linear-gradient(180deg, var(--info-dim-strong) 0%, var(--info-dim) 100%);
|
||||||
|
border-top-color: var(--info-border);
|
||||||
|
}
|
||||||
|
.banner-partial::before { background: var(--info); }
|
||||||
|
.banner-partial .banner-icon { background: var(--info-dim-strong); border: 1px solid var(--info-border); color: var(--info); }
|
||||||
|
.banner-partial .banner-head { color: var(--info); }
|
||||||
|
.banner-partial .pill { background: rgba(103, 232, 249, 0.18); color: var(--info); }
|
||||||
|
|
||||||
|
/* AI-inferred — accent blue, AI-sourced */
|
||||||
|
.banner-ai {
|
||||||
|
background: linear-gradient(180deg, var(--accent-dim-strong) 0%, var(--accent-dim) 100%);
|
||||||
|
border-top-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
.banner-ai::before { background: var(--accent); }
|
||||||
|
.banner-ai .banner-icon { background: var(--accent-dim-strong); border: 1px solid var(--accent-border); color: var(--accent); }
|
||||||
|
.banner-ai .banner-head { color: var(--accent); }
|
||||||
|
.banner-ai .pill { background: rgba(96, 165, 250, 0.20); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Nudge — compact strip */
|
||||||
|
.banner-nudge {
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: var(--warning-dim);
|
||||||
|
border-top-color: var(--warning-border);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.banner-nudge::before { background: var(--warning); }
|
||||||
|
.banner-nudge .nudge-icon {
|
||||||
|
width: 16px; height: 16px; flex-shrink: 0; color: var(--warning);
|
||||||
|
}
|
||||||
|
.banner-nudge .nudge-title {
|
||||||
|
flex: 1; font-size: 12.5px; color: var(--text-primary); font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Task lane ====== */
|
||||||
|
.lane {
|
||||||
|
border-left: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
|
}
|
||||||
|
.lane-head {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.lane-head-label {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600; font-size: 13px; color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.lane-body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 14px 14px 10px;
|
||||||
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.section-label {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
|
||||||
|
text-transform: uppercase; color: var(--text-muted-foreground);
|
||||||
|
padding: 0 2px 8px;
|
||||||
|
}
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot-accent { background: var(--accent); }
|
||||||
|
.dot-danger { background: var(--danger); }
|
||||||
|
.section-meta {
|
||||||
|
color: var(--text-muted); font-weight: 500; letter-spacing: 0; text-transform: none;
|
||||||
|
}
|
||||||
|
.fact {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-default);
|
||||||
|
border-left: 3px solid var(--accent); border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.fact + .fact { margin-top: 8px; }
|
||||||
|
.fact-title { font-size: 12.5px; font-weight: 500; color: var(--text-heading); line-height: 1.4; }
|
||||||
|
.fact-meta { margin-top: 3px; font-size: 10.5px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
.failed-pill {
|
||||||
|
padding: 9px 11px; background: var(--bg-card);
|
||||||
|
border: 1px dashed var(--danger-border); border-radius: 8px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 11.5px; color: var(--text-muted-foreground);
|
||||||
|
}
|
||||||
|
.failed-pill-title { flex: 1; color: var(--text-heading); font-weight: 500; }
|
||||||
|
.failed-pill-badge {
|
||||||
|
padding: 1px 6px; border-radius: 4px; font-size: 9.5px;
|
||||||
|
font-weight: 700; letter-spacing: 0.4px;
|
||||||
|
background: var(--danger-dim); color: var(--danger);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
|
||||||
|
.btn-resolve {
|
||||||
|
flex: 1; background: var(--accent); color: #0a0d14;
|
||||||
|
border-color: transparent; font-weight: 600; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
|
||||||
|
|
||||||
|
/* ====== Callouts ====== */
|
||||||
|
.callout {
|
||||||
|
margin-top: 20px; padding: 14px 16px;
|
||||||
|
background: var(--bg-page); border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px; font-size: 13px; color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.55; border-left: 3px solid var(--warning);
|
||||||
|
}
|
||||||
|
.callout strong { color: var(--text-heading); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ====== State detail panels ====== */
|
||||||
|
.states-title {
|
||||||
|
margin-top: 48px; font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600; font-size: 18px; color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.states-sub { margin-top: 4px; color: var(--text-muted-foreground); font-size: 13px; }
|
||||||
|
|
||||||
|
.states {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
background: var(--bg-page); border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px; overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.state-label {
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--border-default);
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600; font-size: 12.5px; color: var(--text-heading);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
.state-body {
|
||||||
|
padding: 0; background: var(--bg-page);
|
||||||
|
min-height: 280px;
|
||||||
|
display: flex; flex-direction: column; justify-content: flex-end;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.state-mini-chat {
|
||||||
|
flex: 1; padding: 14px 16px;
|
||||||
|
font-size: 11px; color: var(--text-muted);
|
||||||
|
display: flex; align-items: flex-end; gap: 6px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.mini-composer {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
padding: 10px 14px; display: flex; gap: 8px; align-items: center;
|
||||||
|
}
|
||||||
|
.mini-input {
|
||||||
|
flex: 1; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-default); border-radius: 8px;
|
||||||
|
padding: 7px 10px; font-size: 11.5px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.mini-send {
|
||||||
|
width: 28px; height: 28px; border-radius: 7px;
|
||||||
|
background: var(--accent); color: #0a0d14; border: 0; font-size: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-caption {
|
||||||
|
padding: 10px 14px 12px; font-size: 11.5px;
|
||||||
|
color: var(--text-muted-foreground); line-height: 1.5;
|
||||||
|
border-top: 1px solid var(--border-default); background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
.state-caption strong { color: var(--text-heading); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ====== Escalate intercept popover ====== */
|
||||||
|
.intercept-wrap {
|
||||||
|
position: relative;
|
||||||
|
padding: 24px 14px 14px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.intercept-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 70px;
|
||||||
|
left: 14px;
|
||||||
|
width: 340px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-hover);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 18px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(96,165,250,0.15);
|
||||||
|
}
|
||||||
|
.intercept-popover::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -7px; left: 40px;
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border-hover);
|
||||||
|
border-bottom: 1px solid var(--border-hover);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.intercept-head {
|
||||||
|
font-family: 'Bricolage Grotesque', sans-serif;
|
||||||
|
font-weight: 600; font-size: 13px; color: var(--text-heading);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.intercept-sub {
|
||||||
|
font-size: 12px; color: var(--text-muted-foreground);
|
||||||
|
line-height: 1.5; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.intercept-options {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.intercept-option {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 12px; border-radius: 8px;
|
||||||
|
background: var(--bg-elevated); border: 1px solid var(--border-default);
|
||||||
|
font-size: 12.5px; color: var(--text-primary);
|
||||||
|
cursor: pointer; text-align: left; width: 100%;
|
||||||
|
transition: border-color 0.12s, background-color 0.12s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.intercept-option:hover { border-color: var(--border-hover); background: var(--bg-sidebar); }
|
||||||
|
.intercept-option.primary {
|
||||||
|
border-color: var(--danger-border); background: var(--danger-dim);
|
||||||
|
}
|
||||||
|
.intercept-option.primary:hover { border-color: var(--danger); background: var(--danger-dim-strong); }
|
||||||
|
.intercept-kbd {
|
||||||
|
margin-left: auto; font-size: 10.5px; color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
background: rgba(148,163,184,0.08);
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mock-btn-row {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.mock-escalate {
|
||||||
|
background: transparent; color: var(--text-muted-foreground);
|
||||||
|
border: 1px solid var(--border-default); padding: 9px 14px;
|
||||||
|
border-radius: 8px; font-size: 12.5px; min-width: 96px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mock-escalate.active {
|
||||||
|
border-color: var(--danger-border); color: var(--danger);
|
||||||
|
background: var(--danger-dim);
|
||||||
|
}
|
||||||
|
.mock-resolve {
|
||||||
|
flex: 1; background: var(--accent); color: #0a0d14;
|
||||||
|
border: 0; font-weight: 600; padding: 9px 12px;
|
||||||
|
border-radius: 8px; font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Partial inline input row */
|
||||||
|
.partial-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(103, 232, 249, 0.08);
|
||||||
|
border: 1px solid var(--info-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px; color: var(--text-primary);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.partial-note-label {
|
||||||
|
font-style: normal; color: var(--info);
|
||||||
|
font-size: 10.5px; font-weight: 700; letter-spacing: 0.6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lane-body::-webkit-scrollbar,
|
||||||
|
.chat-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.lane-body::-webkit-scrollbar-thumb,
|
||||||
|
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">Post-apply outcome states — how we recognize whether a fix worked</div>
|
||||||
|
<div class="page-sub">
|
||||||
|
Hero frame shows the <strong style="color:var(--text-primary)">Verifying</strong> state — what the banner becomes the moment the engineer clicks Apply. Below, four detail panels show the other outcome paths: <strong style="color:var(--text-primary)">Partial apply</strong>, <strong style="color:var(--text-primary)">AI-inferred outcome</strong> from chat, <strong style="color:var(--text-primary)">Escalate-intercept</strong>, and the <strong style="color:var(--text-primary)">Nudge</strong> that appears when the engineer keeps chatting without confirming.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ HERO: VERIFYING ============ -->
|
||||||
|
<div class="frame">
|
||||||
|
|
||||||
|
<div class="chat">
|
||||||
|
<div class="chat-head">
|
||||||
|
<div>
|
||||||
|
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
|
||||||
|
<div class="chat-head-sub">ticket #48213 · in progress · 14:26</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-scroll">
|
||||||
|
<div class="msg ai">
|
||||||
|
<div class="msg-av">AI</div>
|
||||||
|
<div>
|
||||||
|
<div class="msg-body">Given Credential Manager still has entries for the prior tenant, the cleanest path is to clear those and rebuild the local Outlook profile.</div>
|
||||||
|
<div class="msg-meta">14:22</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg user">
|
||||||
|
<div>
|
||||||
|
<div class="msg-body">Okay, I'll run the script now.</div>
|
||||||
|
<div class="msg-meta">14:24</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg-av">ME</div>
|
||||||
|
</div>
|
||||||
|
<div class="msg system">
|
||||||
|
<div class="msg-av">✓</div>
|
||||||
|
<div>
|
||||||
|
<div class="msg-body">Applied fix: Clear cached credentials + rebuild Outlook profile — script completed without errors at 14:24.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VERIFY BANNER (persistent after Apply) -->
|
||||||
|
<div class="composer-wrap">
|
||||||
|
<div class="banner banner-verify" role="region" aria-label="Verify fix outcome">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="banner-body">
|
||||||
|
<div class="banner-head">
|
||||||
|
<span>Verifying</span>
|
||||||
|
<span class="pill">Applied 14:24 · 2m ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="banner-title">Did "Clear cached credentials + rebuild Outlook profile" work?</div>
|
||||||
|
<div class="banner-note">Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn btn-ghost" aria-label="More options" title="Mark partial apply, re-open details">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger-outline">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
Didn't work
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
It worked
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<div class="composer-input">Tell the AI what happened — or click an outcome above</div>
|
||||||
|
<button class="composer-send" aria-label="Send">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task lane: fix is now in "verifying" status — no longer a standalone suggested fix -->
|
||||||
|
<div class="lane">
|
||||||
|
<div class="lane-head">
|
||||||
|
<div class="lane-head-label">Task lane</div>
|
||||||
|
</div>
|
||||||
|
<div class="lane-body">
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="dot dot-accent"></span>
|
||||||
|
What we know
|
||||||
|
<span class="section-meta">· 5 facts</span>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||||
|
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
|
||||||
|
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
|
||||||
|
<div class="fact-meta">promoted 14:22 · from chat</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<button class="btn btn-escalate">Escalate</button>
|
||||||
|
<button class="btn btn-resolve">Resolve</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
<strong>How Verifying works.</strong> Clicking Apply transitions the banner into this state instead of dismissing it. No timeout — the banner stays pinned until the engineer marks <em>Worked</em>, <em>Didn't work</em>, or <em>Partial</em> (overflow). If they ignore it and keep chatting, the Nudge state (panel D below) appears after a few messages. If they hit the task lane's <em>Resolve</em> button without clicking either outcome, we auto-stamp <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">applied_success</code>. If they hit <em>Escalate</em>, panel C intercepts.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ DETAIL PANELS ============ -->
|
||||||
|
<div class="states-title">Outcome branches</div>
|
||||||
|
<div class="states-sub">Four paths from Verifying to a final status. Each one writes to <code style="font-family:'JetBrains Mono',monospace;font-size:12px;background:var(--bg-card);padding:1px 6px;border-radius:3px;color:var(--text-primary)">session_suggested_fixes.status</code> so the AI's next turn has ground truth about what's been tried.</div>
|
||||||
|
|
||||||
|
<div class="states">
|
||||||
|
|
||||||
|
<!-- A. PARTIAL -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">A. Partial apply — "I did some of it"</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="state-mini-chat">…engineer picked "Mark partial…" from the verify banner's overflow menu</div>
|
||||||
|
<div class="banner banner-partial">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="banner-body">
|
||||||
|
<div class="banner-head">
|
||||||
|
<span>Partially applied</span>
|
||||||
|
<span class="pill">Parked</span>
|
||||||
|
</div>
|
||||||
|
<div class="banner-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||||
|
<div class="partial-note">
|
||||||
|
<span class="partial-note-label">Note</span>
|
||||||
|
<span>Ran cred clear — skipped profile rebuild, user in a meeting. Back at 3:30.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn btn-ghost">Edit note</button>
|
||||||
|
<button class="btn btn-danger-outline">Didn't work</button>
|
||||||
|
<button class="btn">Finish it ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-composer">
|
||||||
|
<div class="mini-input">Type a message…</div>
|
||||||
|
<button class="mini-send">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>Status:</strong> <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">applied_partial</code>, with <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">partial_notes</code> free-text. Not terminal — banner stays pinned until engineer marks a terminal outcome, or clicks <em>Finish it</em> to re-run the remainder and flip back to Verifying. AI treats partial as "tried but uncertain" — doesn't re-propose, but doesn't assume failure either.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- B. AI-INFERRED CONFIRM -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">B. AI-inferred outcome — from chat</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;opacity:0.8">
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:8px 12px;font-size:12px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:80%;"><strong style="font-weight:500">Engineer:</strong> "yep that fixed it, thanks"</div>
|
||||||
|
<div style="font-size:10.5px;color:var(--text-muted);padding-right:2px;">14:31 · user message triggered <code style="font-family:'JetBrains Mono',monospace;font-size:10.5px">[FIX_OUTCOME]</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="banner banner-ai">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="banner-body">
|
||||||
|
<div class="banner-head">
|
||||||
|
<span>AI detected outcome</span>
|
||||||
|
<span class="pill">Success · 92%</span>
|
||||||
|
</div>
|
||||||
|
<div class="banner-title">AI thinks the fix resolved the issue — confirm?</div>
|
||||||
|
<div class="banner-note">Based on your message at 14:31. One click closes the session with this fix as the documented resolution.</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn btn-ghost">Not yet</button>
|
||||||
|
<button class="btn btn-danger-outline">No, didn't work</button>
|
||||||
|
<button class="btn btn-success">Confirm · Resolve</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-composer">
|
||||||
|
<div class="mini-input">Type a message…</div>
|
||||||
|
<button class="mini-send">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>Triggered by</strong> the new <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">[FIX_OUTCOME fix_id=… outcome=success]</code> marker from the system prompt. Engineer stays in the loop — the AI <em>proposes</em> the outcome, doesn't set it. One-click accept fires the normal Resolve flow. Works for failure too ("still broken" → <em>No, didn't work</em> pre-selected, with the AI's reasoning shown).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- C. ESCALATE INTERCEPT -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">C. Escalate-intercept — capture outcome before handoff</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="intercept-wrap">
|
||||||
|
<div class="intercept-popover">
|
||||||
|
<div class="intercept-head">Before escalating — what happened with the fix?</div>
|
||||||
|
<div class="intercept-sub">"Clear cached credentials" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.</div>
|
||||||
|
<div class="intercept-options">
|
||||||
|
<button class="intercept-option primary">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
The fix didn't work
|
||||||
|
<span class="intercept-kbd">↵</span>
|
||||||
|
</button>
|
||||||
|
<button class="intercept-option">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
It worked — escalating for another reason
|
||||||
|
</button>
|
||||||
|
<button class="intercept-option">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
|
Never actually applied it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-btn-row">
|
||||||
|
<button class="mock-escalate active">Escalate</button>
|
||||||
|
<button class="mock-resolve">Resolve</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>Fires when</strong> engineer clicks Escalate while a fix is in Verifying (or Partial). Defaults to <em>Didn't work</em> on Enter — common case. <em>Escalating for another reason</em> preserves success; <em>Never applied</em> flips to <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">dismissed</code>. Takes 1s and makes the escalation narrative honest for whoever picks it up.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- D. NUDGE -->
|
||||||
|
<div class="state">
|
||||||
|
<div class="state-label">D. Nudge — passive prompt after a few messages</div>
|
||||||
|
<div class="state-body">
|
||||||
|
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:6px;opacity:0.8;">
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:70%;">"user is rebooting"</div>
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"okay it's back up, signing in now"</div>
|
||||||
|
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"going to try opening Outlook"</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner banner-nudge">
|
||||||
|
<svg class="nudge-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
|
||||||
|
<span class="nudge-title">Did <strong style="color:var(--text-heading)">"Clear cached credentials"</strong> work?</span>
|
||||||
|
<button class="btn btn-ghost" style="padding:4px 10px">Still checking</button>
|
||||||
|
<button class="btn btn-danger-outline" style="padding:4px 10px">No</button>
|
||||||
|
<button class="btn btn-success" style="padding:4px 10px">Yes</button>
|
||||||
|
</div>
|
||||||
|
<div class="mini-composer">
|
||||||
|
<div class="mini-input">Type a message…</div>
|
||||||
|
<button class="mini-send">↑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="state-caption">
|
||||||
|
<strong>Appears after</strong> ~3 post-apply engineer messages with no outcome click. Collapses the verify banner into this thin nudge strip above it so chat space isn't eaten. Passive — never auto-marks anything. <em>Still checking</em> silences the nudge for another 3 messages. Yes/No route to the normal Success / Failed flows.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1673
docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md
Normal file
1673
docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md
Normal file
File diff suppressed because it is too large
Load Diff
1897
docs/FlowAssist_Migration/phase-9-implementation-plan.md
Normal file
1897
docs/FlowAssist_Migration/phase-9-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
384
docs/FlowAssist_Migration/phase-9-script-builder-tab.md
Normal file
384
docs/FlowAssist_Migration/phase-9-script-builder-tab.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# FlowPilot Phase 9 — Tabbed Script Builder + NoTemplateDialog relocation
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Branch target:** `feat/flowpilot-migration` (continuation of Phases 0–8)
|
||||||
|
**Depends on:** Phase 8 (ProposalBanner in chat region)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Close the two remaining open items from the FlowPilot migration handoff:
|
||||||
|
|
||||||
|
1. **NoTemplateDialog narrow-lane bug** — today the dialog renders in the task lane (~340px) and its `grid-cols-1 sm:grid-cols-3` layout crushes the three option cards. When the AI proposes a fix with no drafted script, all three cards render disabled, producing a dead end.
|
||||||
|
2. **Tabbed Script Builder inside the chat** — give the engineer a way to draft the missing script without leaving the session (either by chatting with the AI or hand-writing in a code editor), then feed the draft back into the existing fix lifecycle.
|
||||||
|
|
||||||
|
Plus two Phase 8 cleanup items flagged during code review:
|
||||||
|
|
||||||
|
3. **`EscalateInterceptDialog` missing the Partial choice** — if a fix is in `applied_partial` when the engineer escalates, the intercept dialog's current three choices (worked / didn't work / never applied) don't match. Add a fourth choice for partial.
|
||||||
|
4. **`applied_at` semantics correction** — today Phase 8's `handleApplyFix` stamps `applied_at` on every banner Apply click, starting the Verifying timer even when the engineer is only opening a drafting/evaluation surface. Move the stamp to the actual run-action handlers (see §5).
|
||||||
|
|
||||||
|
This phase depends on Phase 8's `ProposalBanner` already being in the chat region — it reuses the same "chat-region-owns-Apply-flow" philosophy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | When a fix has no `ai_drafted_script`, the banner's Apply button routes **directly** to the Script Builder tab (bypassing `NoTemplateDialog` entirely). | Banner is the single entry point for Apply. `NoTemplateDialog` stays narrowly scoped to evaluating a draft that actually exists. |
|
||||||
|
| 2 | Inside the Script Builder tab, the default experience is AI-driven — a new `ScriptBuilderTab` controller owns session lifecycle + submit, and *renders* `ScriptBuilderChat` (which stays purely presentational). A "✎ Write it myself" button in the tab's header toolbar swaps the controller's render into a Monaco editor. | AI is the common path. Persistence semantics belong on the controller, not the chat display component (`ScriptBuilderChat` already exposes `onSaveScript` as its seam — the controller wires that callback). |
|
||||||
|
| 3 | The manual editor uses **Monaco**, reusing the pattern from `frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx`. | Monaco is already a dependency (`@monaco-editor/react` + `monaco-editor`). No bundle cost, proven pattern. |
|
||||||
|
| 4 | The Script Builder tab is **always present while the fix is non-terminal** (no close affordance). An **indicator dot** on the tab signals in-progress draft state. | Matches Phase 8's `display: none` philosophy — engineers move freely between chat and draft without tracking a separate open/close state. |
|
||||||
|
| 5 | `NoTemplateDialog` (draft-exists case) moves from `TaskLane.bottomSlot` to the **chat region** (sibling of `ProposalBanner`, slides up above composer). | Script evaluation is an action surface, not a context surface — belongs with the other action surfaces. Chat region is wide enough for the three cards to actually fit side-by-side. |
|
||||||
|
| 6 | `EscalateInterceptDialog` gains a **fourth "Partial" choice** that writes `applied_partial` with a notes prompt. | Closes the gap flagged in Phase 8 final review. Minimal incremental cost since the dialog is already getting touched. |
|
||||||
|
| 7 | `applied_at` is stamped only when the engineer commits to an action that **runs or triggers** a script — not on banner Apply click. Opening a drafting/evaluation surface no longer starts the Verifying timer. | Prevents false "applied" state when the engineer is still authoring. Corrects a Phase 8 over-eager stamp that this phase would otherwise multiply across three surfaces. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Chat region gets a tab strip
|
||||||
|
|
||||||
|
A two-tab strip at the top of the chat region:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ [Chat] [Script Builder ●] │
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ (content per active tab, │
|
||||||
|
│ via display:none toggling) │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **When the strip renders:** only when an `activeFix` exists AND the fix is non-terminal AND (`fix.ai_drafted_script` is null AND `fix.script_template_id` is null) — i.e., the fix genuinely needs a script drafted. Otherwise the chat region shows without tabs.
|
||||||
|
- **Tab switching uses `display: none`**, not unmount. Chat scroll position, draft message, and Script Builder state all persist across switches.
|
||||||
|
- **Indicator dot** on the Script Builder tab fires when there's in-progress draft state: at least one AI message sent in the `ScriptBuilderChat`, or non-empty Monaco buffer. Clears when the draft is submitted.
|
||||||
|
- **Session switch** clears tab state via the existing `resetSessionDerivedState` helper.
|
||||||
|
|
||||||
|
### 2. Script Builder tab content
|
||||||
|
|
||||||
|
A new controller component `ScriptBuilderTab` owns the inline lifecycle:
|
||||||
|
- Creates / resumes a `script_builder_sessions` row with `origin='pilot_inline'` + `ai_session_id = <pilot session id>`.
|
||||||
|
- Manages AI-chat message state (via the existing script-builder message endpoints) and the Monaco editor buffer.
|
||||||
|
- On submit, fires `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`.
|
||||||
|
|
||||||
|
`ScriptBuilderChat` itself is **unchanged** — it stays a pure display component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. The controller wires `onSaveScript` to its submit path instead of the template-creation path the standalone `/script-builder` page uses.
|
||||||
|
|
||||||
|
A header toolbar above the controller's render area hosts the mode toggle:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ Script Builder · Outlook fix │
|
||||||
|
│ [✎ Write myself]│
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ (mode-specific content) │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Clicking **✎ Write myself** flips `scriptBuilderMode` to `'editor'` — the controller renders Monaco in place of `ScriptBuilderChat`, pre-loaded with a scaffold (fix description as a language-appropriate comment header + an empty body).
|
||||||
|
- A reciprocal **✨ Back to AI** button in editor mode returns to the chat.
|
||||||
|
- Switching modes **does not discard** work. The Monaco buffer and the script-builder session both persist across toggles. This matters when an engineer drafts with AI, switches to editor to tweak a line, then considers going back.
|
||||||
|
- Both modes share a single terminal action: the controller's **Submit → `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`**. On success the fix gains `ai_drafted_script`; the tab strip disappears (since the fix no longer needs a script) and the banner's Apply button now routes to `NoTemplateDialog` in the chat region.
|
||||||
|
- **Submit does NOT stamp `applied_at`.** A draft is not an application — see §5 Apply lifecycle below.
|
||||||
|
|
||||||
|
### 3. NoTemplateDialog relocation to chat region
|
||||||
|
|
||||||
|
- Removed from `TaskLane.bottomSlot`. Renders in the chat region, slide-up-above-composer (same mechanical placement as `ProposalBanner`).
|
||||||
|
- The three-card layout (`grid-cols-3` at the chat region's natural width) actually fits — no `grid-cols-1` regression needed.
|
||||||
|
- Opens when the engineer clicks Apply on the banner AND `fix.ai_drafted_script` is non-empty.
|
||||||
|
- Decision semantics unchanged (still `one_off` / `draft_template` / `build_template` with the same server-side effects) except for the moved apply stamp — see §5. Only the render location changes beyond that.
|
||||||
|
|
||||||
|
### 4. Banner Apply routing (updated)
|
||||||
|
|
||||||
|
Three mutually-exclusive outcomes based on the fix's shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
handleApplyFix():
|
||||||
|
if fix.script_template_id:
|
||||||
|
open TemplateMatchPanel (unchanged — still renders in task lane for now)
|
||||||
|
elif fix.ai_drafted_script:
|
||||||
|
open NoTemplateDialog in chat region (new location, Chat tab)
|
||||||
|
else:
|
||||||
|
open Script Builder tab in chat region (new tab)
|
||||||
|
```
|
||||||
|
|
||||||
|
The NoTemplateDialog-in-chat-region path lives on the **Chat tab** (slides up above composer; the tab strip only renders for the no-draft case, so when NoTemplateDialog shows, the tab strip is not on screen). The Script Builder tab path is the opposite — tab strip renders, engineer is on the Script Builder tab.
|
||||||
|
|
||||||
|
`TemplateMatchPanel` stays in the task lane for this phase — it's a different surface with different interactions and it's not broken. Moving it is possible future work.
|
||||||
|
|
||||||
|
### 5. Apply lifecycle — `applied_at` semantics correction
|
||||||
|
|
||||||
|
**Problem.** Today (Phase 8) `handleApplyFix` calls `POST /apply` the moment the banner's Apply button is clicked, stamping `applied_at` regardless of what happens next. This starts the Verifying timer (nudge countdown, Resolve auto-success, Escalate intercept) even if the engineer is only opening a drafting surface and hasn't actually run anything yet. For the no-draft path introduced in this phase, that's clearly wrong — opening the Script Builder tab is the start of *authoring*, not the start of *verifying*.
|
||||||
|
|
||||||
|
**Rule.** `applied_at` is stamped **only when the engineer commits to an action that produces or triggers a run**, not when they open a surface:
|
||||||
|
|
||||||
|
| Banner Apply click → routes to... | Stamps `applied_at`? |
|
||||||
|
|---|---|
|
||||||
|
| `TemplateMatchPanel` (existing flow) | Only when the engineer clicks a new explicit **"✓ I ran this"** action inside the panel (see below) |
|
||||||
|
| `NoTemplateDialog` → `one_off` card ("Run now, no template") | **Yes** — the card click declares "I'm running this now" |
|
||||||
|
| `NoTemplateDialog` → `draft_template` card ("Run now, templatize after") | **Yes** — same declaration, the template proposal is a side effect |
|
||||||
|
| `NoTemplateDialog` → `build_template` card ("Just open the builder") | No — no run is declared; the engineer is going off to author a proper template |
|
||||||
|
| Script Builder tab → Submit | No — just produces a draft. Engineer then clicks Apply again, gets `NoTemplateDialog`, picks `one_off` or `draft_template` to declare the run |
|
||||||
|
|
||||||
|
**New explicit "I ran this" action in `TemplateMatchPanel`.** Today the panel has Generate, Copy, and Edit Parameters — none of which commit to running. Copying doesn't imply running; the engineer can walk away. This phase adds a distinct primary button (accent-colored, below Copy) labeled **"✓ I ran this"** or **"Mark as applied"**. Click → calls `applyFix` → fix transitions to Verifying. Until clicked, the fix stays in `proposed`.
|
||||||
|
|
||||||
|
**Implementation.**
|
||||||
|
- Remove `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix`. Move it to the three run-declaring call sites: `NoTemplateDialog`'s `handleScriptDecision('one_off' | 'draft_template')` paths AND the new `TemplateMatchPanel` "I ran this" button. The `applyFix` endpoint itself (from Phase 8 Issue #2) stays unchanged — only its call sites move.
|
||||||
|
- Until `applied_at` is stamped, the fix remains in `proposed`. `bannerMode` computation already returns `'proposed'` when `applied_at` is null, so the banner naturally stays on Proposed state through the entire drafting phase.
|
||||||
|
- **Phase 8 consequence.** This is a semantic revision of Phase 8, not just Phase 9 behavior. Tests must assert: opening `TemplateMatchPanel` does NOT stamp `applied_at`; clicking "I ran this" DOES; `NoTemplateDialog` `one_off` AND `draft_template` both DO; `build_template` does NOT.
|
||||||
|
|
||||||
|
### 6. EscalateInterceptDialog partial choice
|
||||||
|
|
||||||
|
Adds a fourth button to the existing popover:
|
||||||
|
|
||||||
|
| Existing choices | New choice |
|
||||||
|
|---|---|
|
||||||
|
| The fix didn't work | (existing) |
|
||||||
|
| It worked — escalating for another reason | (existing) |
|
||||||
|
| Never actually applied it | (existing) |
|
||||||
|
| **I applied some of it — partial** | **NEW** |
|
||||||
|
|
||||||
|
- When clicked: prompts for partial notes (same pattern as the banner's Partial path — `window.prompt` for now, matching Phase 8's interim), then calls `patchOutcome('applied_partial', notes)`.
|
||||||
|
- `handleInterceptChoice` gains an `applied_partial` branch. The `InterceptChoice` type already includes `'applied_partial'` via `FixOutcome | 'never_applied'`, so no type changes needed.
|
||||||
|
- When a fix enters the dialog already in `applied_partial` state, the fourth button is hidden (can't transition partial → partial with different semantics). The "didn't work" button remains available to progress to `applied_failed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### New migration
|
||||||
|
|
||||||
|
`script_builder_sessions` **already has** `ai_session_id` (FK → `ai_sessions.id`, nullable, `ON DELETE SET NULL`) with the comment "Link to FlowPilot session if launched from there." The existing column is the link we need — no new FK is added. The migration introduces only the `origin` discriminator plus a uniqueness guard for inline sessions:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE script_builder_sessions
|
||||||
|
ADD COLUMN origin VARCHAR(20) NOT NULL DEFAULT 'standalone';
|
||||||
|
|
||||||
|
ALTER TABLE script_builder_sessions
|
||||||
|
ADD CONSTRAINT ck_script_builder_sessions_origin
|
||||||
|
CHECK (origin IN ('standalone', 'pilot_inline'));
|
||||||
|
|
||||||
|
-- Invariant: pilot_inline rows must be linked to a pilot session.
|
||||||
|
-- Standalone rows may or may not be linked (legacy back-channel).
|
||||||
|
ALTER TABLE script_builder_sessions
|
||||||
|
ADD CONSTRAINT ck_script_builder_sessions_origin_ai_session
|
||||||
|
CHECK (origin <> 'pilot_inline' OR ai_session_id IS NOT NULL);
|
||||||
|
|
||||||
|
-- Uniqueness: at most one pilot_inline session per (user, pilot session).
|
||||||
|
-- Required to back the get-or-create semantics on the endpoint and prevent
|
||||||
|
-- duplicate scratch rows on remount. Partial index scoped to pilot_inline
|
||||||
|
-- so standalone rows are unaffected.
|
||||||
|
CREATE UNIQUE INDEX ux_script_builder_sessions_pilot_inline
|
||||||
|
ON script_builder_sessions (user_id, ai_session_id)
|
||||||
|
WHERE origin = 'pilot_inline';
|
||||||
|
```
|
||||||
|
|
||||||
|
`origin = 'standalone'` → existing `/script-builder` page usage (existing rows backfill to this default). `origin = 'pilot_inline'` → new Script Builder tab; `ai_session_id` is populated at row creation.
|
||||||
|
|
||||||
|
`origin` earns its keep as an explicit discriminator for:
|
||||||
|
- Filtering (`list_sessions` / `count_user_sessions` exclude `pilot_inline` by default — see §Data model filter changes below).
|
||||||
|
- Future split-quota billing (decided to count as one billable session for now, but tagged for analytics).
|
||||||
|
|
||||||
|
### Data-model filter changes — `script_builder_sessions` list + count
|
||||||
|
|
||||||
|
Inline sessions would otherwise pollute the standalone `/script-builder` dashboard and count against the per-user 5-session cap enforced by the `POST /script-builder/sessions` endpoint. Required changes:
|
||||||
|
|
||||||
|
- `script_builder_service.list_sessions(user_id)` → default scope `origin = 'standalone'`. Callers that genuinely want all rows (e.g., an admin dashboard in a future phase) can pass an explicit `include_inline=True` flag, but no current caller needs it.
|
||||||
|
- `script_builder_service.count_user_sessions(user_id)` → same scope.
|
||||||
|
- Both changes covered by tests:
|
||||||
|
- 5 `pilot_inline` sessions should still leave the engineer free to create 5 standalone sessions (no cap interaction).
|
||||||
|
- `list_sessions` returns only `standalone` rows.
|
||||||
|
|
||||||
|
### New backend endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script
|
||||||
|
```
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai_drafted_script": "string (required, 1..50_000 chars)",
|
||||||
|
"ai_drafted_parameters": { /* optional JSONB */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Auth: `require_engineer_or_admin` + `_load_session_or_404`.
|
||||||
|
- 404 if fix not found on that session.
|
||||||
|
- 409 if fix is in a terminal status (`applied_success`, `applied_failed`, `dismissed`) — a drafted script can't be attached after the fix is done.
|
||||||
|
- Sets `fix.ai_drafted_script` + `fix.ai_drafted_parameters`.
|
||||||
|
- **Does NOT stamp `fix.applied_at`.** A draft is not an application — see §5 above.
|
||||||
|
- **Bumps `ai_sessions.state_version`** — the fix just transitioned from "needs drafting" to "has draft", which affects Resolve/Escalate preview regeneration.
|
||||||
|
- Returns `SessionSuggestedFixResponse`.
|
||||||
|
|
||||||
|
### ScriptBuilderTab controller (frontend) — no changes to `ScriptBuilderChat`
|
||||||
|
|
||||||
|
`ScriptBuilderChat` (`frontend/src/components/script-builder/ScriptBuilderChat.tsx`) is a presentational component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. **It does not need a `mode` prop** — adding persistence semantics to a display component would be wrong.
|
||||||
|
|
||||||
|
Instead, introduce a new controller component `frontend/src/components/pilot/ScriptBuilderTab.tsx` that owns the inline lifecycle:
|
||||||
|
|
||||||
|
- On mount: **get-or-create** the single inline `script_builder_sessions` row for `(current user, current pilot session)` via the existing `POST /script-builder/sessions` endpoint, passing `origin: 'pilot_inline'` and the current pilot session id for `ai_session_id`. The endpoint becomes idempotent for `origin='pilot_inline'` — if a row exists for that `(user_id, ai_session_id)` pair, it's returned; otherwise created. The partial unique index on the DB backs the invariant independent of endpoint code. Remounting (tab hide/show, page refresh) resumes the same session — no duplicates, no lost draft continuity.
|
||||||
|
- Holds local state for the AI message list, the Monaco buffer, and `scriptBuilderMode`.
|
||||||
|
- Renders `ScriptBuilderChat` in AI mode with `onSaveScript` wired to the inline submit path (PATCH /script), NOT the standalone template-creation path.
|
||||||
|
- Renders Monaco (via existing `CodeModeEditor` pattern) in `'editor'` mode with its own Save button that triggers the same submit.
|
||||||
|
- Emits an `onScriptDrafted` event to `AssistantChatPage` on success so the page can `setActiveFix(updated)`, hide the tab strip, and return the engineer to Chat tab.
|
||||||
|
|
||||||
|
The standalone `/script-builder` page retains its current behavior unchanged — it continues to create `script_templates` rows on submit. The split happens cleanly at the controller layer, not inside `ScriptBuilderChat`.
|
||||||
|
|
||||||
|
### `POST /script-builder/sessions` — changes for inline origin
|
||||||
|
|
||||||
|
The existing endpoint is extended in three ways:
|
||||||
|
|
||||||
|
1. **Accepts `origin`** in the request body (`Literal['standalone', 'pilot_inline']`, default `'standalone'`). Legacy callers unchanged.
|
||||||
|
2. **Authorization on `ai_session_id`.** When `origin='pilot_inline'` is passed AND `ai_session_id` is provided, the handler MUST verify the referenced `ai_sessions` row is owned by the current user (or within their account — whichever guard `_load_session_or_404(db, ai_session_id, current_user)` already enforces for the pilot endpoints). Without this check, a caller could attach an inline scratch session to an arbitrary pilot session. The check fires before any row lookup or creation.
|
||||||
|
3. **Idempotent for `origin='pilot_inline'`.** If a row with `(user_id = current, ai_session_id = provided, origin = 'pilot_inline')` already exists, the handler returns that row (200) instead of creating a new one (201). The unique partial index enforces at-most-one at the DB layer; a race between two concurrent POSTs surfaces as an integrity error that the handler catches and re-reads.
|
||||||
|
|
||||||
|
For `origin='standalone'`, behavior is unchanged — always creates, still subject to the 5-session cap.
|
||||||
|
|
||||||
|
The 5-session cap applies only to `standalone` rows (see §Data-model filter changes). Inline sessions are out of that accounting entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
### Frontend state (AssistantChatPage)
|
||||||
|
|
||||||
|
New local state on the page:
|
||||||
|
- `chatTab: 'chat' | 'script_builder'` — which tab is visible. Defaults to `'chat'`.
|
||||||
|
- `scriptBuilderHasProgress: boolean` — drives the indicator dot. Set by `ScriptBuilderTab` via an `onProgressChange` callback.
|
||||||
|
|
||||||
|
Reset in `resetSessionDerivedState`: both back to defaults.
|
||||||
|
|
||||||
|
`scriptBuilderMode` ('ai' | 'editor') lives **inside `ScriptBuilderTab`**, not on the page — the parent never needs to drive the AI/editor toggle. The controller resets it naturally via unmount/remount when the page switches sessions.
|
||||||
|
|
||||||
|
Banner's Apply handler (`handleApplyFix`) updated:
|
||||||
|
- If no script + no template → set `chatTab = 'script_builder'` (and show tab strip).
|
||||||
|
- If drafted script → open NoTemplateDialog in the chat region (new state or existing `scriptPanelOpen` reused).
|
||||||
|
- If template → open `TemplateMatchPanel` in the task lane (render location unchanged); run stamping happens via the new "I ran this" action inside the panel (see §5), not on Apply click.
|
||||||
|
|
||||||
|
### Tab strip visibility
|
||||||
|
|
||||||
|
The tab strip is derived, not state:
|
||||||
|
```ts
|
||||||
|
const showTabStrip =
|
||||||
|
activeFix != null &&
|
||||||
|
activeFix.status !== 'dismissed' &&
|
||||||
|
activeFix.status !== 'applied_success' &&
|
||||||
|
activeFix.status !== 'applied_failed' &&
|
||||||
|
!activeFix.script_template_id &&
|
||||||
|
!activeFix.ai_drafted_script
|
||||||
|
```
|
||||||
|
|
||||||
|
When the strip hides (e.g., after script is drafted), `chatTab` resets to `'chat'` to avoid stuck state.
|
||||||
|
|
||||||
|
### Tab switching guard
|
||||||
|
|
||||||
|
The existing `currentChatRef` pattern (Async-select-load-apply guard) applies: when the engineer switches chats, any in-flight tab-derived state is discarded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **NoTemplateDialog grid fix.** Moved to the chat region (wide enough), so the `grid-cols-1 sm:grid-cols-3` layout now works as intended. No grid edit required.
|
||||||
|
- **`window.prompt` replacement** for partial-notes / failure-reason capture. Still the Phase 8 interim pattern; replacement is deferred to a later design debt pass.
|
||||||
|
- **TemplateMatchPanel relocation** to the chat region. Different surface, different interactions, not broken today. Possible future work.
|
||||||
|
- **Dedicated "clear AI outcome proposal" button in the UI.** Already covered by Phase 8 Issue #3 fix (DELETE endpoint + clear-on-outcome-write).
|
||||||
|
- **Task lane bottom-slot audit.** With NoTemplateDialog removed from the slot, it may be empty on most sessions. Keep the slot API stable; any cleanup is out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Migration:** forward + downgrade reversibility; existing rows backfill to `origin='standalone'`; the `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL` invariant is enforced by the check constraint.
|
||||||
|
- **PATCH /script endpoint** (new test file `test_fix_script_endpoint.py`):
|
||||||
|
- happy path — 200, `ai_drafted_script` set, `state_version` bumped, `applied_at` untouched.
|
||||||
|
- 404 on wrong session.
|
||||||
|
- 409 on terminal status.
|
||||||
|
- 400 on empty body.
|
||||||
|
- **list/count filter changes** (extend `test_script_builder.py` or nearby):
|
||||||
|
- 5 `pilot_inline` sessions + subsequent `standalone` session creation succeeds (does not hit the 5-cap).
|
||||||
|
- `list_sessions` returns only `standalone` rows by default.
|
||||||
|
- **Apply lifecycle correction** (extend `test_fix_outcome_endpoint.py`):
|
||||||
|
- Banner Apply click that routes to a drafting/evaluation surface does NOT stamp `applied_at`.
|
||||||
|
- `one_off` decision from `NoTemplateDialog` DOES stamp `applied_at`.
|
||||||
|
- `draft_template` decision from `NoTemplateDialog` DOES stamp `applied_at` (it still runs the script).
|
||||||
|
- `build_template` decision from `NoTemplateDialog` does NOT stamp (no run).
|
||||||
|
- `TemplateMatchPanel` "I ran this" action DOES stamp `applied_at`; Generate / Copy alone do NOT.
|
||||||
|
- **Script Builder session create — inline semantics** (extend `test_script_builder.py` or equivalent):
|
||||||
|
- First `POST /script-builder/sessions` with `origin='pilot_inline', ai_session_id=X` creates and returns a row.
|
||||||
|
- Second `POST` with the same `(ai_session_id, user)` returns the SAME row (no duplicate created); DB row count confirms.
|
||||||
|
- `POST` with `origin='pilot_inline'` and `ai_session_id` pointing at another user's pilot session is rejected (403/404).
|
||||||
|
- Race: two concurrent `POST`s for the same `(user, ai_session_id)` resolve to the same row id (one winner, one returns the existing).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Manual verification (no component test harness in this codebase per CLAUDE.md):
|
||||||
|
- No-draft fix → Apply click opens Script Builder tab.
|
||||||
|
- AI path: chat with AI, submit, tab disappears, NoTemplateDialog becomes eligible.
|
||||||
|
- Manual path: ✎ Write myself → Monaco loads with scaffold → edit → submit → tab disappears.
|
||||||
|
- Drafted fix → Apply click opens NoTemplateDialog in chat region (three cards side-by-side).
|
||||||
|
- Tab indicator dot appears on first AI message / non-empty Monaco buffer; clears on submit.
|
||||||
|
- Session switch with open Script Builder tab → tab/mode state resets.
|
||||||
|
- EscalateInterceptDialog partial choice → applied_partial written with notes.
|
||||||
|
|
||||||
|
### Build discipline
|
||||||
|
|
||||||
|
- `tsc -b` clean
|
||||||
|
- `npm run build` clean
|
||||||
|
- `docker exec resolutionflow_backend pytest` — all pre-existing suites still pass, no regression from the new endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to touch (rough inventory)
|
||||||
|
|
||||||
|
**Backend — new:**
|
||||||
|
- `backend/alembic/versions/<hash>_script_builder_origin.py`
|
||||||
|
- `backend/tests/test_fix_script_endpoint.py`
|
||||||
|
|
||||||
|
**Backend — modified:**
|
||||||
|
- `backend/app/models/script_builder_session.py` — add `origin` column only (`ai_session_id` already exists).
|
||||||
|
- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixScriptRequest`.
|
||||||
|
- `backend/app/schemas/script_builder.py` — extend `ScriptBuilderCreateRequest` with two new optional fields: `origin: Literal['standalone', 'pilot_inline'] = 'standalone'` and `ai_session_id: UUID | None = None`. Handler-side validation: when `origin='pilot_inline'`, `ai_session_id` is required (not null) AND must pass the current-user ownership check. Legacy callers pass neither and continue to create standalone sessions as before.
|
||||||
|
- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH /script endpoint. Move the existing `applied_at` stamp out of the apply path and into `handleScriptDecision('one_off' | 'draft_template')` plus `TemplateMatchPanel`'s new "I ran this" handler (server side: no change to `/apply`; callers shift instead).
|
||||||
|
- `backend/app/api/endpoints/script_builder.py` — accept `origin` on session creation; enforce the `pilot_inline ⇒ ai_session_id` invariant at the handler level.
|
||||||
|
- `backend/app/services/script_builder_service.py` — persist `origin`; `list_sessions` + `count_user_sessions` filter to `origin='standalone'` by default.
|
||||||
|
- `backend/app/models/session_suggested_fix.py` — unchanged (schema already has `ai_drafted_script`).
|
||||||
|
|
||||||
|
**Frontend — new:**
|
||||||
|
- `frontend/src/components/pilot/ChatTabStrip.tsx` — renders the `[Chat] [Script Builder ●]` strip.
|
||||||
|
- `frontend/src/components/pilot/ScriptBuilderTab.tsx` — controller that owns session lifecycle, AI message state, Monaco buffer, mode toggle, and submit. Renders `ScriptBuilderChat` in AI mode and Monaco in editor mode.
|
||||||
|
- `frontend/src/components/pilot/NoTemplateDialogInline.tsx` (or reuse existing `NoTemplateDialog` with a new wrapper for chat-region styling).
|
||||||
|
|
||||||
|
**Frontend — modified:**
|
||||||
|
- `frontend/src/api/sessionSuggestedFixes.ts` — add `patchScript(sessionId, fixId, body, parameters)` method.
|
||||||
|
- `frontend/src/api/scriptBuilder.ts` (or equivalent) — `createSession` accepts optional `origin` and `ai_session_id` arguments (both required together when the caller is `ScriptBuilderTab`; both omitted for the legacy standalone caller).
|
||||||
|
- `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — **unchanged**. Stays a pure display component.
|
||||||
|
- `frontend/src/pages/ScriptBuilderPage.tsx` — **unchanged on the session-creation path** (defaults to `origin='standalone'`).
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx` — wire tab strip, mount `ScriptBuilderTab`, banner Apply routing (no `applied_at` stamp on click), NoTemplateDialog chat-region render. Move the `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix` to `handleScriptDecision('one_off' | 'draft_template')` and `TemplateMatchPanel`'s new "I ran this" handler.
|
||||||
|
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — add fourth choice.
|
||||||
|
- `frontend/src/components/pilot/TaskLane.tsx` — remove `bottomSlot` usage of NoTemplateDialog (leave prop API stable).
|
||||||
|
|
||||||
|
**Frontend — deleted:**
|
||||||
|
- None (existing components get refactored, not deleted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
- Single branch, merged as part of the in-flight `feat/flowpilot-migration` PR (same as Phase 8).
|
||||||
|
- No feature flag — the new surface is strictly additive to the banner's Apply flow; old behavior for drafted-script fixes is preserved (just renders in a different location).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open deferrals (acknowledged, not in this phase)
|
||||||
|
|
||||||
|
- `window.prompt` → inline input migration for partial notes / failure reasons.
|
||||||
|
- Anti-parrot compliance check for the inline `ScriptBuilderTab` flow — verify it reuses the existing script-builder AI system prompt (no new prompt content introduced; the controller only changes what `onSaveScript` does, not what the AI sees).
|
||||||
|
- Telemetry events for tab opens / AI→editor toggles / script submissions from tab — add in the Phase 9 implementation plan if we want them.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Lessons Archive (1-40)
|
# Lessons Archive (1-70)
|
||||||
|
|
||||||
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
|
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
|
||||||
|
|
||||||
@@ -81,3 +81,67 @@
|
|||||||
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
|
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
|
||||||
|
|
||||||
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
|
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archived Lessons (41-70)
|
||||||
|
|
||||||
|
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
||||||
|
|
||||||
|
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
||||||
|
|
||||||
|
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
||||||
|
|
||||||
|
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
||||||
|
|
||||||
|
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
||||||
|
|
||||||
|
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
||||||
|
|
||||||
|
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Delta responses use `[DELTA]...[/DELTA]` markers.
|
||||||
|
|
||||||
|
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
||||||
|
|
||||||
|
**49. Full-stack features — verify both ends:** schema → endpoint → API client → hook → store → UI.
|
||||||
|
|
||||||
|
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
||||||
|
|
||||||
|
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: alias form (`claude-sonnet-4-6`).
|
||||||
|
|
||||||
|
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
||||||
|
|
||||||
|
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
||||||
|
|
||||||
|
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
||||||
|
|
||||||
|
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
||||||
|
|
||||||
|
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||||
|
|
||||||
|
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
||||||
|
|
||||||
|
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
||||||
|
|
||||||
|
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||||
|
|
||||||
|
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only without this; `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
||||||
|
|
||||||
|
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
|
||||||
|
|
||||||
|
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
|
||||||
|
|
||||||
|
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||||
|
|
||||||
|
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
|
||||||
|
|
||||||
|
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
|
||||||
|
|
||||||
|
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
|
||||||
|
|
||||||
|
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
|
||||||
|
|
||||||
|
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
|
||||||
|
|
||||||
|
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
|
||||||
|
|
||||||
|
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.
|
||||||
|
|||||||
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ConnectWise integration docs
|
||||||
|
|
||||||
|
Reference material for ResolutionFlow's ConnectWise Manage integration.
|
||||||
|
This folder pairs a **human-editable source** (the XLSX) with two
|
||||||
|
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
|
||||||
|
read the Markdown; edits happen in the XLSX.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Role | Edit? |
|
||||||
|
|------|------|-------|
|
||||||
|
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
|
||||||
|
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
|
||||||
|
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
|
||||||
|
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
|
||||||
|
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
|
||||||
|
|
||||||
|
## Regeneration workflow
|
||||||
|
|
||||||
|
After editing the XLSX:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs/integrations/connectwise/source
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python generate_role_docs.py \
|
||||||
|
--source Security_Roles_Matrix_11132017.xlsx \
|
||||||
|
--out-yaml ../api-member-security-roles.yaml \
|
||||||
|
--out-md ../api-member-security-roles.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
|
||||||
|
is what reviewers should scrutinize — it is the source of truth for code.
|
||||||
|
|
||||||
|
## Querying the YAML from integration code
|
||||||
|
|
||||||
|
The YAML groups permissions by module and action. Example — checking
|
||||||
|
what `Inquire: ALL` means for Service Desk → Service Tickets:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
doc = yaml.safe_load(
|
||||||
|
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
|
||||||
|
)
|
||||||
|
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
|
||||||
|
print(levels["ALL"])
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
|
||||||
|
layer should use when the required permission level for a given API
|
||||||
|
endpoint needs to be documented or validated against an assigned role.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
|
||||||
|
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
|
||||||
|
- **`Not applicable` notes** in a verb's cell mean the meaningful level
|
||||||
|
is documented under another verb (almost always `inquire`) — the
|
||||||
|
generator preserves these as `note:` fields rather than inventing
|
||||||
|
placeholder levels.
|
||||||
|
- **The XLSX is the single source of input.** Never hand-edit the YAML
|
||||||
|
or Markdown; your changes will be overwritten on the next regeneration.
|
||||||
Binary file not shown.
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
File diff suppressed because it is too large
Load Diff
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
File diff suppressed because it is too large
Load Diff
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""
|
||||||
|
Generate ConnectWise security-role documentation from the source XLSX.
|
||||||
|
|
||||||
|
Produces:
|
||||||
|
- api-member-security-roles.yaml : machine-readable source of truth
|
||||||
|
- api-member-security-roles.md : human-readable reference
|
||||||
|
|
||||||
|
Re-run this script after editing the source XLSX. Both outputs are
|
||||||
|
deterministic — they will produce identical content from identical input,
|
||||||
|
so diffs in version control reflect only real permission-model changes.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python generate_role_docs.py \
|
||||||
|
--source source/Security_Roles_Matrix_11132017.xlsx \
|
||||||
|
--out-yaml ../api-member-security-roles.yaml \
|
||||||
|
--out-md ../api-member-security-roles.md
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# A level description line looks like "ALL: text..." or "NONE: text..."
|
||||||
|
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
|
||||||
|
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
|
||||||
|
|
||||||
|
# Recognized ConnectWise permission levels, most-to-least privileged.
|
||||||
|
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
|
||||||
|
|
||||||
|
VERBS = ["add", "edit", "delete", "inquire"]
|
||||||
|
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CellPermission:
|
||||||
|
"""Parsed contents of a single (action, verb) cell."""
|
||||||
|
|
||||||
|
levels: Dict[str, str] = field(default_factory=dict) # level -> description
|
||||||
|
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
|
||||||
|
raw: str = "" # original cell text, preserved for audit
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionRow:
|
||||||
|
module: str
|
||||||
|
action: str
|
||||||
|
permissions: Dict[str, CellPermission] # verb -> CellPermission
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cell(raw: Optional[str]) -> CellPermission:
|
||||||
|
"""Parse a single cell's multi-line content into levels + note."""
|
||||||
|
if raw is None:
|
||||||
|
return CellPermission(raw="")
|
||||||
|
text = str(raw).strip()
|
||||||
|
cp = CellPermission(raw=text)
|
||||||
|
if not text:
|
||||||
|
return cp
|
||||||
|
|
||||||
|
# Split into candidate entries. Each entry is typically one line that
|
||||||
|
# starts with a level prefix, but description text can itself contain
|
||||||
|
# newlines. We therefore split on newlines and accumulate continuation
|
||||||
|
# lines into the preceding entry.
|
||||||
|
current_level: Optional[str] = None
|
||||||
|
current_buf: List[str] = []
|
||||||
|
note_buf: List[str] = []
|
||||||
|
|
||||||
|
def flush_level() -> None:
|
||||||
|
nonlocal current_level, current_buf
|
||||||
|
if current_level is not None:
|
||||||
|
cp.levels[current_level] = " ".join(current_buf).strip()
|
||||||
|
current_level = None
|
||||||
|
current_buf = []
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = LEVEL_LINE.match(line)
|
||||||
|
if m:
|
||||||
|
flush_level()
|
||||||
|
current_level = m.group(1).upper()
|
||||||
|
current_buf = [m.group(2).strip()]
|
||||||
|
elif current_level is not None:
|
||||||
|
current_buf.append(line)
|
||||||
|
else:
|
||||||
|
# No level prefix yet — belongs to the note.
|
||||||
|
note_buf.append(line)
|
||||||
|
flush_level()
|
||||||
|
|
||||||
|
if note_buf:
|
||||||
|
cp.note = " ".join(note_buf).strip()
|
||||||
|
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
|
||||||
|
wb = load_workbook(xlsx_path, data_only=True)
|
||||||
|
ws = wb.active # Single sheet in this workbook.
|
||||||
|
|
||||||
|
# Header row is row 2 per the source file; data begins row 3.
|
||||||
|
actions: List[ActionRow] = []
|
||||||
|
for r in range(3, ws.max_row + 1):
|
||||||
|
module = ws.cell(row=r, column=1).value
|
||||||
|
action = ws.cell(row=r, column=2).value
|
||||||
|
if not (module or action):
|
||||||
|
continue # skip fully empty rows
|
||||||
|
if not module or not action:
|
||||||
|
# Partial row — keep but flag. This shouldn't happen in the
|
||||||
|
# current source; if it does, the generator should fail loudly
|
||||||
|
# rather than silently produce wrong output.
|
||||||
|
raise ValueError(
|
||||||
|
f"Row {r} has a missing Module or Action: "
|
||||||
|
f"module={module!r}, action={action!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
perms: Dict[str, CellPermission] = {}
|
||||||
|
for verb, col in VERB_COLS.items():
|
||||||
|
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
|
||||||
|
|
||||||
|
actions.append(
|
||||||
|
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output: YAML
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
|
||||||
|
"""Build a plain-dict representation that YAML dumps cleanly."""
|
||||||
|
# Group by module, preserving action order within each module.
|
||||||
|
modules: Dict[str, List[ActionRow]] = {}
|
||||||
|
for a in actions:
|
||||||
|
modules.setdefault(a.module, []).append(a)
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"metadata": {
|
||||||
|
"source_file": source_file,
|
||||||
|
"generated_on": date.today().isoformat(),
|
||||||
|
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
|
||||||
|
"description": (
|
||||||
|
"ConnectWise security-role matrix. Each (module, action) entry "
|
||||||
|
"describes what each access level (ALL, MY, MINE, NONE) means "
|
||||||
|
"for the Add, Edit, Delete, and Inquire verbs. This is a "
|
||||||
|
"reference catalog, not a per-role assignment — role "
|
||||||
|
"assignments live in ConnectWise and are mirrored in the "
|
||||||
|
"ResolutionFlow integration config."
|
||||||
|
),
|
||||||
|
"level_order_most_to_least_privileged": LEVEL_ORDER,
|
||||||
|
},
|
||||||
|
"modules": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for module_name, rows in modules.items():
|
||||||
|
module_block = {"actions": {}}
|
||||||
|
for a in rows:
|
||||||
|
action_block: Dict[str, object] = {}
|
||||||
|
for verb in VERBS:
|
||||||
|
cell = a.permissions[verb]
|
||||||
|
entry: Dict[str, object] = {}
|
||||||
|
if cell.levels:
|
||||||
|
# Emit levels in canonical order, only those present.
|
||||||
|
entry["levels"] = {
|
||||||
|
lvl: cell.levels[lvl]
|
||||||
|
for lvl in LEVEL_ORDER
|
||||||
|
if lvl in cell.levels
|
||||||
|
}
|
||||||
|
if cell.note:
|
||||||
|
entry["note"] = cell.note
|
||||||
|
if not entry:
|
||||||
|
# Truly empty cell — represent explicitly so downstream
|
||||||
|
# consumers can distinguish "empty" from "missing".
|
||||||
|
entry["note"] = "(no description provided)"
|
||||||
|
action_block[verb] = entry
|
||||||
|
module_block["actions"][a.action] = action_block
|
||||||
|
doc["modules"][module_name] = module_block
|
||||||
|
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class _LiteralStr(str):
|
||||||
|
"""Marker type so PyYAML renders long strings as block literals."""
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_presenter(dumper, data):
|
||||||
|
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
||||||
|
|
||||||
|
|
||||||
|
yaml.add_representer(_LiteralStr, _literal_presenter)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_block_style_for_long_strings(obj):
|
||||||
|
"""Recursively wrap long strings so the YAML is readable, not one-line."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_use_block_style_for_long_strings(v) for v in obj]
|
||||||
|
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
|
||||||
|
return _LiteralStr(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def dump_yaml(doc: dict, out_path: Path) -> None:
|
||||||
|
prepared = _use_block_style_for_long_strings(doc)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with out_path.open("w", encoding="utf-8") as f:
|
||||||
|
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
|
||||||
|
f.write("# Generated from the source XLSX; do not edit by hand.\n")
|
||||||
|
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
|
||||||
|
yaml.dump(
|
||||||
|
prepared,
|
||||||
|
f,
|
||||||
|
sort_keys=False,
|
||||||
|
allow_unicode=True,
|
||||||
|
width=100,
|
||||||
|
default_flow_style=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output: Markdown
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _md_escape(text: str) -> str:
|
||||||
|
"""Escape pipes and collapse whitespace for Markdown table cells."""
|
||||||
|
return text.replace("|", "\\|").replace("\n", " ").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
|
||||||
|
modules: Dict[str, List[ActionRow]] = {}
|
||||||
|
for a in actions:
|
||||||
|
modules.setdefault(a.module, []).append(a)
|
||||||
|
|
||||||
|
lines: List[str] = []
|
||||||
|
lines.append("# ConnectWise API Member — Security Roles Reference")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"_Generated {date.today().isoformat()} from "
|
||||||
|
f"`{source_file}`. Do not edit by hand — update the XLSX and "
|
||||||
|
f"re-run `generate_role_docs.py`._"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## How to read this document")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Each ConnectWise module lists the actions it governs. For every "
|
||||||
|
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
|
||||||
|
"**Inquire** — can be granted at one of these levels, most to "
|
||||||
|
"least privileged:"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Level | Meaning |")
|
||||||
|
lines.append("|-------|---------|")
|
||||||
|
lines.append("| `ALL` | Access to all records in the system. |")
|
||||||
|
lines.append("| `MY` | Access to records owned by the user's team. |")
|
||||||
|
lines.append("| `MINE` | Access only to records owned by the user. |")
|
||||||
|
lines.append("| `NONE` | No access. |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Not every level applies to every action — the source matrix "
|
||||||
|
"only documents the levels that are meaningful for each cell. "
|
||||||
|
"Cells marked _Not applicable_ reference another verb (usually "
|
||||||
|
"Inquire) where the meaningful level is defined."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"The machine-readable form of this document is "
|
||||||
|
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
|
||||||
|
"Use the YAML when writing integration code; use this Markdown "
|
||||||
|
"when reviewing, discussing, or onboarding."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Table of contents")
|
||||||
|
lines.append("")
|
||||||
|
for module_name in modules:
|
||||||
|
anchor = module_name.lower().replace(" ", "-").replace("/", "")
|
||||||
|
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for module_name, rows in modules.items():
|
||||||
|
lines.append(f"## {module_name}")
|
||||||
|
lines.append("")
|
||||||
|
for a in rows:
|
||||||
|
lines.append(f"### {a.action}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Verb | Level | Description |")
|
||||||
|
lines.append("|------|-------|-------------|")
|
||||||
|
wrote_any = False
|
||||||
|
for verb in VERBS:
|
||||||
|
cell = a.permissions[verb]
|
||||||
|
if cell.levels:
|
||||||
|
for lvl in LEVEL_ORDER:
|
||||||
|
if lvl in cell.levels:
|
||||||
|
lines.append(
|
||||||
|
f"| {verb.capitalize()} | `{lvl}` | "
|
||||||
|
f"{_md_escape(cell.levels[lvl])} |"
|
||||||
|
)
|
||||||
|
wrote_any = True
|
||||||
|
elif cell.note:
|
||||||
|
lines.append(
|
||||||
|
f"| {verb.capitalize()} | — | "
|
||||||
|
f"_{_md_escape(cell.note)}_ |"
|
||||||
|
)
|
||||||
|
wrote_any = True
|
||||||
|
if not wrote_any:
|
||||||
|
lines.append("| — | — | _(no description provided)_ |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def write_markdown(md_text: str, out_path: Path) -> None:
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(md_text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source", type=Path, required=True,
|
||||||
|
help="Path to the source .xlsx")
|
||||||
|
parser.add_argument("--out-yaml", type=Path, required=True,
|
||||||
|
help="Path to write the YAML output")
|
||||||
|
parser.add_argument("--out-md", type=Path, required=True,
|
||||||
|
help="Path to write the Markdown output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
actions = read_matrix(args.source)
|
||||||
|
doc = build_yaml_document(actions, source_file=args.source.name)
|
||||||
|
dump_yaml(doc, args.out_yaml)
|
||||||
|
|
||||||
|
md = build_markdown(actions, source_file=args.source.name)
|
||||||
|
write_markdown(md, args.out_md)
|
||||||
|
|
||||||
|
# Quick data-quality summary to stdout — helpful when re-running after edits.
|
||||||
|
from collections import Counter
|
||||||
|
modules_seen = Counter(a.module for a in actions)
|
||||||
|
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
|
||||||
|
for m, n in modules_seen.most_common():
|
||||||
|
print(f" {m}: {n}")
|
||||||
|
print(f"\nWrote {args.out_yaml}")
|
||||||
|
print(f"Wrote {args.out_md}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
docs/connectwise/CW_Security_Roles/requirements.txt
Normal file
5
docs/connectwise/CW_Security_Roles/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Dependencies for generate_role_docs.py.
|
||||||
|
# These are only needed when regenerating the role docs from the XLSX —
|
||||||
|
# they are not runtime dependencies of ResolutionFlow itself.
|
||||||
|
openpyxl>=3.1,<4.0
|
||||||
|
PyYAML>=6.0,<7.0
|
||||||
88
docs/handoff/2026-04-22-flowpilot-migration.md
Normal file
88
docs/handoff/2026-04-22-flowpilot-migration.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
date: 2026-04-22
|
||||||
|
branch: feat/flowpilot-migration
|
||||||
|
remote: ssh://gitea.resolutionflow.com/chihlasm/resolutionflow.git
|
||||||
|
last_commit: faf1d8d fix(pilot): applied_at stamps on run-declaring actions, not Apply click
|
||||||
|
status: Sprint 9/9 phases complete and pushed; PR not yet opened. Open items #1 and #3 resolved by Phase 9.
|
||||||
|
---
|
||||||
|
|
||||||
|
# FlowPilot Migration — Session Handoff
|
||||||
|
|
||||||
|
## Where the work lives
|
||||||
|
|
||||||
|
- Branch: `feat/flowpilot-migration` (pushed to Gitea, mirrors to GitHub)
|
||||||
|
- Spec: [docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md](../FlowAssist_Migration/FLOWPILOT-MIGRATION.md)
|
||||||
|
- Mockups: [docs/FlowAssist_Migration/mockups/](../FlowAssist_Migration/mockups/) (PNG + HTML reference)
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
All nine migration phases are merged onto the branch and verified against the live dev stack (`resolutionflow_frontend` / `resolutionflow_backend` / `resolutionflow_postgres` containers).
|
||||||
|
|
||||||
|
| Phase | Commit | What landed |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 — baseline telemetry | (pre-branch) | analytics events for funnel deltas |
|
||||||
|
| 1 — `/assistant` → `/pilot` rename | early commits | route redirects, sidebar updates |
|
||||||
|
| 2 — What we know (facts) | (mid) | `session_facts` table, `[PROMOTE]` marker, fact CRUD endpoints, `WhatWeKnow` section |
|
||||||
|
| 3 — Suggested fix + Resolve preview | `7ccf4c6` and prior | `session_suggested_fixes`, `[SUGGEST_FIX]` marker, `ResolutionNotePreview` popover |
|
||||||
|
| 4 — Escalate + PSA writeback | `8fd2c1b` | `psa_writeback_service` with status verification, kind-parameterized preview |
|
||||||
|
| 5 — inline Script Generator | `fa61376` | `TemplateMatchPanel`, `NoTemplateDialog` three-option dialog |
|
||||||
|
| 6 — post-resolve templatize | `4aaf57a` | `draft_templates` table, accept/reject endpoints, `TemplatizePrompt` modal, account preferences |
|
||||||
|
| 7 — polish | `8a242f5` | loading/empty states, keyboard shortcuts (`⌘↵`, `⌘G`, `?` overlay), responsive bottom-drawer <1200px |
|
||||||
|
| 8 — Fix Outcome Banner | `cdd8bb0`..`a47ce07` | Six outcome columns on `session_suggested_fixes` (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`) + `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/outcome` endpoint + `[FIX_OUTCOME]` marker; replaces task-lane `SuggestedFix` card with a chat-composer-anchored `ProposalBanner` (5 states: proposed/verifying/partial/ai_confirming/nudge + collapsed); `EscalateInterceptDialog` captures outcome before handoff; Resolve-while-verifying auto-marks success; 17 new tests (8 endpoint + 7 marker + 2 anti-parrot) |
|
||||||
|
| 9 — Tabbed Script Builder | `5bcb7aa`..`faf1d8d` | Chat-region tab strip (`[Chat] [Script Builder ●]`) with `ChatTabStrip` + new `ScriptBuilderTab` controller wrapping the existing `ScriptBuilderChat` + Monaco editor (`ScriptBodyEditor`); `InlineNoTemplateDialog` relocates the existing `NoTemplateDialog` from the narrow task-lane `bottomSlot` to a chat-region placement wrapper; `EscalateInterceptDialog` gains a fourth "partial" choice; `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint for engineer-drafted scripts (does not stamp `applied_at`); Alembic migration adds `origin VARCHAR(20)` to `script_builder_sessions` (reuses existing `ai_session_id` FK) + partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` for idempotent get-or-create; `applied_at` semantics corrected to stamp only on run-declaring actions (`handleScriptDecision` for `one_off`/`draft_template`; new `onMarkRun` on `TemplateMatchPanel`) — not the Apply click |
|
||||||
|
|
||||||
|
Plus the structural fixes that came up along the way:
|
||||||
|
- `50215b9` + `d0ebdef` — full sweep removing literal payloads from AI system prompts; new `tests/test_prompt_anti_parrot.py` guardrail
|
||||||
|
- `ce7c8ac` + `ddae171` — task-lane state-leak across chats (centralized `resetSessionDerivedState()` helper)
|
||||||
|
- `8879f96` — dropped `sticky top-0` from all four lane section headers (they were orphaning over unrelated content on scroll)
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
|
||||||
|
1. `git checkout feat/flowpilot-migration`
|
||||||
|
2. `docker compose -f docker-compose.dev.yml up -d` (if the stack isn't running)
|
||||||
|
3. Verify: `docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"` should be clean
|
||||||
|
4. Live URL: <http://localhost:5173/pilot> (or `<host-ip>:5173/pilot`)
|
||||||
|
5. Test users (password `TestPass123!`): `engineer@resolutionflow.example.com`
|
||||||
|
|
||||||
|
## Open work — pick one
|
||||||
|
|
||||||
|
Items #1 and #3 were discovered during Phase 6/7 verification. Item #2 was resolved by Phase 8. Items #1 and #3 are **resolved by Phase 9** (see below).
|
||||||
|
|
||||||
|
### 1. NoTemplateDialog narrow-lane bug
|
||||||
|
**Status: RESOLVED by Phase 9.**
|
||||||
|
|
||||||
|
Phase 9 relocated `InlineNoTemplateDialog` from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper (`InlineNoTemplateDialog.tsx`). The dialog no longer renders inside the narrow 380px task lane, eliminating the `sm:grid-cols-3` viewport-breakpoint collision. The disabled-cards bug (when no `ai_drafted_script` is present) is also resolved: when no draft exists, the engineer is routed into the new `ScriptBuilderTab` inline chat instead of reaching the three-option dialog with disabled cards.
|
||||||
|
|
||||||
|
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
|
||||||
|
|
||||||
|
### 2. Task lane crowding / Suggested Fix discoverability
|
||||||
|
**Status: RESOLVED by Phase 8.** The `SuggestedFix` card no longer lives inside the scrollable task lane. Phase 8 replaced it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that is always visible at the bottom of the conversation column regardless of how far the task lane has scrolled. The banner is the primary entry point for fix application; the task lane retains a compact read-only summary of the active fix for reference.
|
||||||
|
|
||||||
|
See [docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md](../FlowAssist_Migration/phase-8-fix-outcome-banner.md) for the implementation plan and design rationale. Because the banner is now the primary entry point, the NoTemplateDialog narrow-lane bug (open item #1) is considerably less visible — the three-option dialog is only reached after the engineer opts in via the banner, at which point they have already acknowledged the fix.
|
||||||
|
|
||||||
|
### 3. Tabbed Script Builder inside the chat (Option A from the modal-vs-tab discussion)
|
||||||
|
**Status: RESOLVED by Phase 9.**
|
||||||
|
|
||||||
|
Phase 9 shipped the complete tabbed Script Builder integration. The chat region now has a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip`) powered by a new `ScriptBuilderTab` controller that wraps the existing (untouched) `ScriptBuilderChat` for AI mode and `ScriptBodyEditor` (Monaco) for a "Write it myself" editor mode. `display: none` toggling preserves chat scroll position, draft message, and editor buffer across tab switches.
|
||||||
|
|
||||||
|
The `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint writes `ai_drafted_script` + `ai_drafted_parameters` back to the fix record without stamping `applied_at` — a draft is not an application. Bumps `state_version` so cached Resolve/Escalate previews regenerate.
|
||||||
|
|
||||||
|
The migration added `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` (with CHECK constraint on the two valid values + invariant that `origin='pilot_inline'` requires `ai_session_id IS NOT NULL`) to `script_builder_sessions`. It reuses the pre-existing `ai_session_id` FK rather than adding a new parent column. A partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency from the inline tab.
|
||||||
|
|
||||||
|
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
|
||||||
|
|
||||||
|
## Loose ends / things to verify on resume
|
||||||
|
|
||||||
|
- **PR not opened.** Branch is pushed but no Gitea PR yet. When ready: `gh pr create` works against the GitHub mirror, but the actual review happens in Gitea.
|
||||||
|
- **`/ultrareview` not run** on the final state of the branch (including Phase 9). Worth doing before PR creation.
|
||||||
|
- **Phase 9 browser QA not done.** The new tab strip, `ScriptBuilderTab` (AI + editor modes), `InlineNoTemplateDialog` chat-region placement, and `EscalateInterceptDialog` fourth-choice flow have not been exercised in a headless-browser session. Key states to cover: tab strip renders and toggles without unmounting chat or losing editor buffer; Script Builder tab Submit persists script via PATCH without stamping `applied_at`; `one_off`/`draft_template` decisions DO stamp; `build_template` does NOT stamp; `TemplateMatchPanel` "I ran this" stamps via `onMarkRun`; partial-attempt choice in `EscalateInterceptDialog` is recorded correctly.
|
||||||
|
- **Phase 8 browser QA not done.** The `ProposalBanner` and `EscalateInterceptDialog` (three-choice variant) have not been exercised in a headless-browser session. Key states: banner appears on `[FIX_OUTCOME]` marker; banner dismisses correctly; escalate mid-fix triggers dialog; banner auto-collapses after session resolved. Use `/qa` or `/design-review` against `mockups/06-slide-up-banner.html` and `mockups/07-verify-states.html`.
|
||||||
|
- **Phase 7 visual verification was structural only** — `tsc -b` and `npm run build` both clean, HMR applied each change without error, but no headless-browser screenshot comparison against the mockup PNGs. If you want pixel-level verification, `/qa` or `/design-review` would catch deltas.
|
||||||
|
- **Anti-parrot test runs as part of `pytest`** but is not enforced in any specific CI step yet — verify `tests/test_prompt_anti_parrot.py` is discovered by the existing pytest run, and consider failing CI explicitly on regression.
|
||||||
|
|
||||||
|
## Files most likely to need attention next
|
||||||
|
|
||||||
|
- [frontend/src/pages/AssistantChatPage.tsx](../../frontend/src/pages/AssistantChatPage.tsx) — 1500+ lines, the central pilot orchestrator. Most state-leak and rendering bugs surface here first. Search for `resetSessionDerivedState` to see the chat-switch reset pattern.
|
||||||
|
- [frontend/src/components/assistant/TaskLane.tsx](../../frontend/src/components/assistant/TaskLane.tsx) — accepts `whatWeKnowSlot` / `bottomSlot` from the parent, plus a `variant: 'side' | 'drawer'` for responsive. `bottomSlot` remains active (carries `TemplateMatchPanel` + resolve/escalate preview buttons in both side and drawer variants).
|
||||||
|
- [backend/app/services/unified_chat_service.py](../../backend/app/services/unified_chat_service.py) — owns marker parsing for `[PROMOTE]`, `[SUGGEST_FIX]`, `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[TREE_UPDATE]`. If markers stop firing in chat, this is the first place to check.
|
||||||
|
- [backend/app/services/assistant_chat_service.py](../../backend/app/services/assistant_chat_service.py) — `ASSISTANT_SYSTEM_PROMPT` constant. Anti-parrot test enforces no literal payloads here; use `<placeholder>` syntax only.
|
||||||
3075
docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Normal file
3075
docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
|||||||
|
# PSA Ticket Management — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-16
|
||||||
|
**Status:** Approved
|
||||||
|
**Author:** Michael Chihlas + Claude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add PSA ticket management to ResolutionFlow so MSP engineers can view, manage, and create ConnectWise tickets without leaving the app. The feature surfaces in three places: a dedicated Tickets page, a dashboard widget on QuickStartPage, and a spin-off ticket flow inside ResolutionAssist sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|----------|----------|
|
||||||
|
| Where does ticket management live? | Both: dedicated `/tickets` page + dashboard widget on QuickStartPage |
|
||||||
|
| List layout | Flat list with rich filters + pagination |
|
||||||
|
| Row density | Compact single-line rows |
|
||||||
|
| Ticket detail | Right-side slide-out panel (~50% width) |
|
||||||
|
| Ticket creation | Two-tab modal: Quick Create (AI) + Full Form |
|
||||||
|
| Resource member list | All CW members, RF-mapped users visually highlighted |
|
||||||
|
| Architecture | Dedicated `ticket_service.py` + normalized DTOs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1 — Backend
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
|
||||||
|
All added to `backend/app/api/endpoints/integrations.py`, backed by `backend/app/services/ticket_service.py`.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `POST` | `/integrations/psa/tickets` | Create a ticket |
|
||||||
|
| `PATCH` | `/integrations/psa/tickets/{id}/status` | Update ticket status |
|
||||||
|
| `GET` | `/integrations/psa/tickets/{id}/resources` | List current assignees |
|
||||||
|
| `POST` | `/integrations/psa/tickets/{id}/resources` | Add a resource (member) |
|
||||||
|
| `DELETE` | `/integrations/psa/tickets/{id}/resources/{member_id}` | Remove a resource |
|
||||||
|
| `POST` | `/integrations/psa/tickets/ai-parse` | Natural language → structured pre-fill payload |
|
||||||
|
|
||||||
|
**Breaking change — `search_tickets` response shape updated to `TicketListResponse`.**
|
||||||
|
The existing `/integrations/psa/tickets/search` endpoint currently returns `list[PSATicketSearchResult]`. This spec changes it to return `TicketListResponse` (adds `total`, `page`, `page_size` wrapper).
|
||||||
|
|
||||||
|
Current callers that must be migrated:
|
||||||
|
- `integrationsApi.searchTickets()` in `frontend/src/api/integrations.ts` (line 18) — update return type
|
||||||
|
- `integrationsApi.searchTicketsQueue()` in `frontend/src/api/integrations.ts` (line 20) — update return type
|
||||||
|
- `frontend/src/components/dashboard/TicketQueue.tsx` — update to read `.items` from response
|
||||||
|
- `frontend/src/components/session/TicketPickerModal.tsx` — update to read `.items` from response
|
||||||
|
|
||||||
|
All other existing endpoints (`get_ticket`, `get_ticket_statuses`, `list_members`, `list_boards`) are unchanged.
|
||||||
|
|
||||||
|
### ticket_service.py
|
||||||
|
|
||||||
|
New service wrapping the PSA provider for ticket mutations. Keeps `integrations.py` clean and PSA-agnostic for future Autotask support.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
- `create_ticket(account_id, payload) → PSATicketCreated`
|
||||||
|
- `add_resource(account_id, ticket_id, member_id) → PSAResource`
|
||||||
|
- `remove_resource(account_id, ticket_id, member_id) → None`
|
||||||
|
- `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate`
|
||||||
|
- `list_resources(account_id, ticket_id) → list[PSAResource]`
|
||||||
|
|
||||||
|
### PSA Provider — New Abstract Methods and Paginated Result Type
|
||||||
|
|
||||||
|
**New type in `backend/app/services/psa/types.py`:**
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PaginatedTicketResult:
|
||||||
|
items: list[PSATicket]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
```
|
||||||
|
|
||||||
|
**`search_tickets` signature change** — updated on both the abstract base and `ConnectWiseProvider` to return `PaginatedTicketResult` instead of `list[PSATicket]`:
|
||||||
|
```python
|
||||||
|
# base.py
|
||||||
|
@abstractmethod
|
||||||
|
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**How `total` is fetched** — ConnectWise provides `GET /service/tickets/count?conditions=...` which accepts the same conditions string as the page fetch. The `ConnectWiseProvider.search_tickets()` implementation fires two parallel requests:
|
||||||
|
1. `GET /service/tickets?conditions=...&pageSize=N&page=N` — the current page
|
||||||
|
2. `GET /service/tickets/count?conditions=...` — returns `{ "count": 142 }`
|
||||||
|
|
||||||
|
Both use the same built conditions string. `asyncio.gather()` runs them in parallel. The count result is used to populate `PaginatedTicketResult.total`.
|
||||||
|
|
||||||
|
**New abstract methods** added to `PSAProvider` base and `ConnectWiseProvider`:
|
||||||
|
```python
|
||||||
|
async def list_resources(self, ticket_id: int) -> list[PSAResource]: ...
|
||||||
|
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ...
|
||||||
|
async def remove_resource(self, ticket_id: int, member_id: int) -> None: ...
|
||||||
|
async def create_ticket(self, payload: TicketCreatePayload) -> PSATicketCreated: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`update_status` already exists on the provider — no change needed there.
|
||||||
|
|
||||||
|
ConnectWise implementation:
|
||||||
|
- `list_resources` → `GET /service/tickets/{id}/members`
|
||||||
|
- `add_resource` → `POST /service/tickets/{id}/members`
|
||||||
|
- `remove_resource` → `DELETE /service/tickets/{id}/members/{member_id}`
|
||||||
|
- `create_ticket` → `POST /service/tickets`
|
||||||
|
|
||||||
|
### Normalized DTOs (Pydantic Schemas)
|
||||||
|
|
||||||
|
New schemas in `backend/app/schemas/psa_tickets.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PSAResource(BaseModel):
|
||||||
|
member_id: int
|
||||||
|
member_name: str
|
||||||
|
member_identifier: str # CW username
|
||||||
|
is_rf_user: bool # True if mapped in RF member mappings
|
||||||
|
|
||||||
|
class PSATicketCreated(BaseModel):
|
||||||
|
id: int
|
||||||
|
summary: str
|
||||||
|
board_name: str
|
||||||
|
status_name: str
|
||||||
|
priority_name: str
|
||||||
|
company_name: str
|
||||||
|
resources: list[PSAResource]
|
||||||
|
|
||||||
|
class PSATicketStatusUpdate(BaseModel):
|
||||||
|
ticket_id: int
|
||||||
|
previous_status: str
|
||||||
|
new_status: str
|
||||||
|
|
||||||
|
class TicketCreatePayload(BaseModel):
|
||||||
|
summary: str
|
||||||
|
company_id: int
|
||||||
|
board_id: int
|
||||||
|
status_id: int
|
||||||
|
priority_id: int
|
||||||
|
description: str | None = None
|
||||||
|
assigned_member_id: int | None = None
|
||||||
|
|
||||||
|
class TicketListResponse(BaseModel):
|
||||||
|
items: list[PSATicketSearchResult] # existing schema
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
```
|
||||||
|
|
||||||
|
`search_tickets` endpoint updated to return `TicketListResponse` (was a plain list). Backend sorts results by `priority desc, dateEntered desc` via CW `orderBy` param.
|
||||||
|
|
||||||
|
### AI Parse Endpoint
|
||||||
|
|
||||||
|
`POST /integrations/psa/tickets/ai-parse`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{ "prompt": "New ticket for Acme Corp, Outlook not syncing, high priority, assign to me" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response — all pre-fill fields nullable, explicit `missing_fields` and `warnings`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "Outlook not syncing",
|
||||||
|
"company_id": 42,
|
||||||
|
"board_id": null,
|
||||||
|
"priority_id": null,
|
||||||
|
"status_id": null,
|
||||||
|
"assigned_member_id": 17,
|
||||||
|
"description": "User reports Outlook calendar not syncing since yesterday morning.",
|
||||||
|
"missing_fields": ["board_id", "priority_id", "status_id"],
|
||||||
|
"warnings": ["Could not determine board from context"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend uses `missing_fields` to highlight required fields still needing engineer input. No ticket is created at this step — it is a parse-only endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2 — Frontend Architecture
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `pages/TicketsPage.tsx` | Main tickets page — filter bar + paginated list |
|
||||||
|
| `components/tickets/TicketListRow.tsx` | Compact single-line row |
|
||||||
|
| `components/tickets/TicketFilterBar.tsx` | Config-driven filter bar (7 filters) |
|
||||||
|
| `components/tickets/TicketDetailPanel.tsx` | Slide-out panel orchestrator |
|
||||||
|
| `components/tickets/detail/TicketDetailHeader.tsx` | ID, summary, company, board, SLA |
|
||||||
|
| `components/tickets/detail/TicketResourceManager.tsx` | Assignee list + add/remove |
|
||||||
|
| `components/tickets/detail/TicketNotesFeed.tsx` | Chronological notes history |
|
||||||
|
| `components/tickets/detail/TicketAddNote.tsx` | Inline note composer |
|
||||||
|
| `components/tickets/detail/TicketConfigs.tsx` | Attached devices/configs |
|
||||||
|
| `components/tickets/detail/TicketRelated.tsx` | Related tickets list |
|
||||||
|
| `components/tickets/NewTicketModal.tsx` | Two-tab modal (owns draft state) |
|
||||||
|
| `components/tickets/AiTicketParseForm.tsx` | Prompt input → emits parsed values upward |
|
||||||
|
| `api/tickets.ts` | All ticket API calls (typed, `.then(r => r.data)` pattern) |
|
||||||
|
| `types/tickets.ts` | TypeScript interfaces mirroring normalized DTOs |
|
||||||
|
|
||||||
|
### Existing Files Touched
|
||||||
|
|
||||||
|
- `router.tsx` — add `/tickets` route (lazy, via `lazyWithRetry`)
|
||||||
|
- `AppLayout.tsx` — add "Tickets" nav item in sidebar under RESOLVE section
|
||||||
|
- `AssistantChatPage.tsx` — handle `create_spin_off_ticket` action type in TaskLane + add "New Ticket" button to session header
|
||||||
|
- `QuickStartPage.tsx` — no structural change needed; `TicketQueue` already renders at line 64. The existing component is updated in place (see Section 4).
|
||||||
|
|
||||||
|
### Shared Types (`types/tickets.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface TicketFilters {
|
||||||
|
search: string;
|
||||||
|
board_id: number | null;
|
||||||
|
status_id: number | null;
|
||||||
|
priority: string | null;
|
||||||
|
company_id: number | null;
|
||||||
|
assigned: 'me' | 'unassigned' | 'all' | number; // number = specific member_id
|
||||||
|
include_closed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketCreationPayload {
|
||||||
|
summary: string;
|
||||||
|
company_id: number | null;
|
||||||
|
board_id: number | null;
|
||||||
|
status_id: number | null;
|
||||||
|
priority_id: number | null;
|
||||||
|
description: string;
|
||||||
|
assigned_member_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiParseResponse {
|
||||||
|
summary: string | null;
|
||||||
|
company_id: number | null;
|
||||||
|
board_id: number | null;
|
||||||
|
priority_id: number | null;
|
||||||
|
status_id: number | null;
|
||||||
|
assigned_member_id: number | null;
|
||||||
|
description: string | null;
|
||||||
|
missing_fields: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PSAResource {
|
||||||
|
member_id: number;
|
||||||
|
member_name: string;
|
||||||
|
member_identifier: string;
|
||||||
|
is_rf_user: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketSearchResult is the existing PSATicketSearchResult type from types/integrations.ts
|
||||||
|
// Re-export or import from there — do not redefine
|
||||||
|
export interface TicketListResponse {
|
||||||
|
items: PSATicketSearchResult[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TicketsPage — Filter & Pagination State
|
||||||
|
|
||||||
|
All filter and pagination state lives in URL query params via `useSearchParams`:
|
||||||
|
|
||||||
|
| Param | Type | Default |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `search` | string | `""` |
|
||||||
|
| `board` | number | — |
|
||||||
|
| `status` | number | — |
|
||||||
|
| `priority` | string | — |
|
||||||
|
| `company` | number | — |
|
||||||
|
| `assigned` | `me \| unassigned \| all \| {id}` | `all` |
|
||||||
|
| `closed` | boolean | `false` |
|
||||||
|
| `page` | number | `1` |
|
||||||
|
|
||||||
|
Filter changes reset `page` to 1. Pagination: page size of 25. Controls show "Showing X–Y of Z tickets". Next disabled when `page * 25 >= total`.
|
||||||
|
|
||||||
|
### TicketFilterBar — Config-Driven
|
||||||
|
|
||||||
|
Filters defined as a `FILTER_CONFIG` array. Each entry:
|
||||||
|
```typescript
|
||||||
|
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
|
||||||
|
```
|
||||||
|
Adding or removing a filter is a one-line config change, not a component edit.
|
||||||
|
|
||||||
|
### TicketDetailPanel — Optimistic Hydration
|
||||||
|
|
||||||
|
The panel uses the **existing** `/integrations/psa/tickets/{id}/context` endpoint (client: `psaContextApi.getTicketContext()` in `frontend/src/api/psaContext.ts`) which already returns company, contact, configurations, notes, and related tickets in one call. This avoids creating redundant endpoints.
|
||||||
|
|
||||||
|
1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
|
||||||
|
2. Two parallel fetches fire on open:
|
||||||
|
- `psaContextApi.getTicketContext(ticketId)` — hydrates contact, notes, configs, related tickets
|
||||||
|
- `ticketsApi.listResources(ticketId)` — hydrates assignees (new endpoint)
|
||||||
|
3. All detail sections (contact, notes, configs, related) render skeletons until `getTicketContext` resolves
|
||||||
|
4. Resources section renders skeleton until `listResources` resolves
|
||||||
|
|
||||||
|
`get_ticket` (the simpler single-ticket endpoint) is **not** used by the panel — `getTicketContext` is a strict superset of the data needed.
|
||||||
|
|
||||||
|
### NewTicketModal — State Ownership
|
||||||
|
|
||||||
|
- `NewTicketModal` owns the `TicketCreationPayload` draft state
|
||||||
|
- `AiTicketParseForm` is a pure emitter: accepts a prompt string, calls `ai-parse`, fires `onParsed(Partial<TicketCreationPayload>)` upward
|
||||||
|
- Modal merges parsed values into draft, highlights `missing_fields` with visual indicators
|
||||||
|
- Two tabs: **Quick Create** (AI prompt → review) | **Full Form** (manual entry)
|
||||||
|
- Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
|
||||||
|
- Initial props: `initialValues?: Partial<TicketCreationPayload>` — used for spin-off pre-population
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3 — ResolutionAssist Integration
|
||||||
|
|
||||||
|
### Two Trigger Paths
|
||||||
|
|
||||||
|
**1. AI-suggested (via `[ACTIONS]` marker)**
|
||||||
|
|
||||||
|
When the AI identifies a second distinct issue during a session, it emits a JSON array inside the `[ACTIONS]` marker — matching the exact format `_parse_actions_marker()` in `unified_chat_service.py` expects (a list of objects with `label`, `command`, `description`):
|
||||||
|
|
||||||
|
```
|
||||||
|
[ACTIONS]
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "Create ticket: Printer offline on 2nd floor",
|
||||||
|
"command": "create_spin_off_ticket",
|
||||||
|
"description": "Printer offline on 2nd floor"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
[/ACTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `_parse_actions_marker()` parser in `unified_chat_service.py` already handles this format — no parser changes needed. The frontend reads `action.command === "create_spin_off_ticket"` to render the "Create Ticket" button in TaskLane, and uses `action.description` as the `summary_hint` pre-populated into the Quick Create prompt input.
|
||||||
|
|
||||||
|
`summary_hint` (from `action.description`) populates the AI prompt input only, not the summary field directly. The engineer still runs the AI parse step and reviews all output. This prevents bypassing review with potentially hallucinated values.
|
||||||
|
|
||||||
|
**2. Engineer-initiated**
|
||||||
|
|
||||||
|
A "New Ticket" button in the ResolutionAssist session header. Always visible regardless of AI suggestion. Opens `NewTicketModal` with Full Form tab as default.
|
||||||
|
|
||||||
|
### Both Paths — NewTicketModal Pre-population
|
||||||
|
|
||||||
|
**The linked ticket IDs problem:** The current `PSATicketInfo` type in `frontend/src/types/integrations.ts` only exposes `company_name` and `board_name` — not `company_id` or `board_id`. The modal needs the numeric IDs to pre-populate the form selects.
|
||||||
|
|
||||||
|
**Fix:** Expand `PSATicketInfo` in `types/integrations.ts` to add the optional ID fields:
|
||||||
|
```typescript
|
||||||
|
export interface PSATicketInfo {
|
||||||
|
id: string
|
||||||
|
summary: string
|
||||||
|
company_name: string | null
|
||||||
|
board_name: string | null
|
||||||
|
status_name: string | null
|
||||||
|
priority_name: string | null
|
||||||
|
company_id: number | null // add
|
||||||
|
board_id: number | null // add
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through.
|
||||||
|
|
||||||
|
**`AssistantChatPage` state change required:** The current page only tracks `activePsaTicketId: string | null` (line 76) — it does not hold a `PSATicketInfo` object. Add a new state field:
|
||||||
|
```typescript
|
||||||
|
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
When the modal is opened (either via AI suggestion or the "New Ticket" button), if `activePsaTicketId` is set and `linkedTicket` is null, fire `integrationsApi.getTicket(activePsaTicketId)` to fetch the full ticket (which now includes `company_id` and `board_id`) and store it in `linkedTicket`. The modal opens immediately — `initialValues` is populated once the fetch resolves and the form fields update. If the fetch is still in flight when the modal opens, `company_id` and `board_id` start empty and fill in when ready.
|
||||||
|
|
||||||
|
Once `linkedTicket` is populated, the modal receives:
|
||||||
|
```typescript
|
||||||
|
initialValues: {
|
||||||
|
company_id: linkedTicket.company_id,
|
||||||
|
board_id: linkedTicket.board_id,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When no linked ticket exists (`activePsaTicketId === null`): `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors.
|
||||||
|
|
||||||
|
### TaskLane Action Lifecycle
|
||||||
|
|
||||||
|
- Opening the modal does **not** remove the action from TaskLane
|
||||||
|
- Dismissing the modal without submitting leaves the action visible
|
||||||
|
- Successful ticket creation removes the action and shows a success toast: `"Ticket #1042 created in ConnectWise"`
|
||||||
|
|
||||||
|
### System Prompt Addition
|
||||||
|
|
||||||
|
New rule added to `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`:
|
||||||
|
|
||||||
|
> When you identify a second distinct issue that is clearly separate from the primary topic of this session, suggest creating a spin-off ticket using the `[ACTIONS]` marker. Use `"command": "create_spin_off_ticket"` and put the issue description in `"description"`. Only suggest this when the issue is genuinely separate — do not suggest for every tangential mention.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **`assistant_chat_service.py`** — system prompt updated with spin-off ticket instruction (above)
|
||||||
|
- **`unified_chat_service.py`** — no parser changes needed; the existing `_parse_actions_marker()` already handles the JSON array format. The frontend reads `command === "create_spin_off_ticket"` to route the action
|
||||||
|
- **`flowpilot_engine.py`** — no changes needed for this feature; guided FlowPilot sessions do not use this action type in the current scope
|
||||||
|
|
||||||
|
No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` and `POST /integrations/psa/tickets/ai-parse`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4 — Dashboard Widget (QuickStartPage)
|
||||||
|
|
||||||
|
### Placement
|
||||||
|
|
||||||
|
`TicketQueue` **already exists** in `QuickStartPage` (line 64, below `ActiveFlowPilotSessions`, above the Dashboard section). It currently auto-hides if no PSA connection exists. This spec updates the existing `TicketQueue` component — it is **not** a new widget and does not need to be added to `QuickStartPage`. The Dashboard section below it is not collapsible.
|
||||||
|
|
||||||
|
### Data Fetching
|
||||||
|
|
||||||
|
On mount: `GET /integrations/psa/member-mappings` first to detect mapping state, then `integrationsApi.searchTicketsQueue({ assigned_to_me: true, include_closed: false, page_size: 5 })` if a mapping exists for the current user.
|
||||||
|
|
||||||
|
`searchTicketsQueue` is used (not `searchTickets`) because it already accepts `assigned_to_me` and `page_size` params. Its return type will be updated to `TicketListResponse` as part of the search endpoint migration, so the widget reads `.items` after that change.
|
||||||
|
|
||||||
|
Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states.
|
||||||
|
|
||||||
|
### Widget States
|
||||||
|
|
||||||
|
| State | Condition | Display |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Hidden | No PSA connection | Widget not rendered |
|
||||||
|
| Prompt | PSA connected, no member mapping | "Map your PSA member to see your queue" → `/account/integrations` |
|
||||||
|
| Loading | Fetching | 3 skeleton rows |
|
||||||
|
| Populated | Tickets returned | Up to 5 compact rows + "View All Tickets →" |
|
||||||
|
| Empty | No assigned open tickets | "No open tickets assigned to you" — muted, no CTA |
|
||||||
|
| Error | PSA fetch fails | Silent — returns `[]`, no toast (per Lesson 111) |
|
||||||
|
|
||||||
|
### Row Display
|
||||||
|
|
||||||
|
Compact row matching Tickets page style: `#ID · Summary · Status badge · Priority dot`
|
||||||
|
|
||||||
|
Clicking a row opens `TicketDetailPanel` as a right-side sheet rendered at the `QuickStartPage` level. Does **not** navigate away.
|
||||||
|
|
||||||
|
### "View All Tickets" Link
|
||||||
|
|
||||||
|
Links to `/tickets?assigned=me`. `TicketsPage` reads `assigned` from `useSearchParams` on mount and applies it as the initial filter state — consistent with Section 2 URL param contract.
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
|
||||||
|
Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the CW API query. Widget does not sort client-side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed Summary
|
||||||
|
|
||||||
|
### New Backend Files
|
||||||
|
- `backend/app/services/ticket_service.py`
|
||||||
|
- `backend/app/schemas/psa_tickets.py`
|
||||||
|
|
||||||
|
### Modified Backend Files
|
||||||
|
- `backend/app/api/endpoints/integrations.py` — 6 new endpoints, update search to return `TicketListResponse`
|
||||||
|
- `backend/app/services/psa/types.py` — add `PaginatedTicketResult` dataclass
|
||||||
|
- `backend/app/services/psa/base.py` — 4 new abstract methods; update `search_tickets` return type to `PaginatedTicketResult`
|
||||||
|
- `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods; update `search_tickets` to fire parallel count request and return `PaginatedTicketResult`; update `_map_ticket()` to pass through `company_id` and `board_id`
|
||||||
|
- `backend/app/schemas/psa_connection.py` — add `company_id` and `board_id` to `PSATicketInfo` Pydantic schema
|
||||||
|
- `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT`
|
||||||
|
- ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature)
|
||||||
|
- ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format)
|
||||||
|
|
||||||
|
### New Frontend Files
|
||||||
|
- `frontend/src/pages/TicketsPage.tsx`
|
||||||
|
- `frontend/src/api/tickets.ts`
|
||||||
|
- `frontend/src/types/tickets.ts`
|
||||||
|
- `frontend/src/components/tickets/TicketListRow.tsx`
|
||||||
|
- `frontend/src/components/tickets/TicketFilterBar.tsx`
|
||||||
|
- `frontend/src/components/tickets/TicketDetailPanel.tsx`
|
||||||
|
- `frontend/src/components/tickets/NewTicketModal.tsx`
|
||||||
|
- `frontend/src/components/tickets/AiTicketParseForm.tsx`
|
||||||
|
- `frontend/src/components/tickets/detail/TicketDetailHeader.tsx`
|
||||||
|
- `frontend/src/components/tickets/detail/TicketResourceManager.tsx`
|
||||||
|
- `frontend/src/components/tickets/detail/TicketNotesFeed.tsx`
|
||||||
|
- `frontend/src/components/tickets/detail/TicketAddNote.tsx`
|
||||||
|
- `frontend/src/components/tickets/detail/TicketConfigs.tsx`
|
||||||
|
- `frontend/src/components/tickets/detail/TicketRelated.tsx`
|
||||||
|
|
||||||
|
### Modified Frontend Files
|
||||||
|
- `frontend/src/router.tsx` — `/tickets` route
|
||||||
|
- `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item
|
||||||
|
- `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header
|
||||||
|
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component (see Section 4 — not a new file)
|
||||||
|
- `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse`
|
||||||
|
- `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo`
|
||||||
|
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap
|
||||||
|
- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Autotask provider implementation (schema-ready, not implemented)
|
||||||
|
- Time entry creation from ticket detail (provider method exists, no UI)
|
||||||
|
- Ticket editing beyond status (summary, description, priority changes)
|
||||||
|
- Bulk ticket operations
|
||||||
|
- Real-time ticket updates / polling
|
||||||
@@ -88,6 +88,8 @@ test.describe('command palette smoke tests', () => {
|
|||||||
|
|
||||||
await flowpilotOption.click()
|
await flowpilotOption.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/assistant/)
|
// Phase 1 of the FlowPilot migration renamed /assistant to /pilot.
|
||||||
|
// /assistant still 301-redirects to /pilot, so accept either landing URL.
|
||||||
|
await expect(page).toHaveURL(/\/(pilot|assistant)/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ test.describe('session history smoke tests', () => {
|
|||||||
await page.goto('/sessions')
|
await page.goto('/sessions')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Sessions', exact: true }),
|
page.getByRole('heading', { name: 'Session History', exact: true }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Default tab on /sessions is "AI Sessions"; flow sessions live behind
|
||||||
|
// the "Flow Sessions" tab and only that tab exposes ticket/client filters.
|
||||||
|
await page.getByRole('button', { name: 'Flow Sessions' }).click()
|
||||||
|
|
||||||
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
|
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
|
||||||
await page.getByPlaceholder('Search by client name...').fill(clientName)
|
await page.getByPlaceholder('Search by client name...').fill(clientName)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test.describe('authenticated navigation smoke tests', () => {
|
|||||||
await page.goto('/sessions')
|
await page.goto('/sessions')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Sessions', exact: true }),
|
page.getByRole('heading', { name: 'Session History', exact: true }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
|
|||||||
await page.goto('/account')
|
await page.goto('/account')
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Account Settings' }),
|
page.getByRole('heading', { name: 'Account Management' }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ test.describe('session resume smoke tests', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto('/trees')
|
// Resume flow moved off /trees onto the Flow Sessions tab of /sessions
|
||||||
|
// during the FlowPilot migration. The destination (/trees/:id/navigate)
|
||||||
|
// is unchanged — only the entry point shifted.
|
||||||
|
await page.goto('/sessions')
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Session History', exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: 'Flow Sessions' }).click()
|
||||||
|
// Active sub-tab is the default and surfaces in-progress sessions.
|
||||||
|
|
||||||
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).filter({ hasText: 'Resume' }).first()
|
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first()
|
||||||
await expect(resumeCard).toBeVisible()
|
await expect(resumeCard).toBeVisible()
|
||||||
await resumeCard.getByRole('button', { name: 'Resume' }).first().click()
|
await resumeCard.getByRole('button', { name: 'Resume' }).first().click()
|
||||||
|
|
||||||
|
|||||||
98
frontend/src/api/draftTemplates.ts
Normal file
98
frontend/src/api/draftTemplates.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Draft templates API — Phase 6 post-resolve templatization flow.
|
||||||
|
*
|
||||||
|
* A draft is produced when the engineer picks "Run now, templatize after
|
||||||
|
* resolve" on the three-option dialog. After Resolve, the TemplatizePrompt
|
||||||
|
* modal lists pending drafts and lets the engineer accept (→ real
|
||||||
|
* script_templates row) or reject.
|
||||||
|
*
|
||||||
|
* Mirrors backend endpoints under /api/v1/draft-templates.
|
||||||
|
*/
|
||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export type DraftStatus = 'pending' | 'accepted' | 'rejected'
|
||||||
|
|
||||||
|
export interface DraftTemplate {
|
||||||
|
id: string
|
||||||
|
account_id: string
|
||||||
|
source_session_id: string
|
||||||
|
source_user_id: string
|
||||||
|
script_body: string
|
||||||
|
proposed_parameters: { parameters?: Array<Record<string, unknown>> } | Record<string, unknown>
|
||||||
|
proposed_name: string | null
|
||||||
|
proposed_category_id: string | null
|
||||||
|
status: DraftStatus
|
||||||
|
resolved_at: string | null
|
||||||
|
promoted_template_id: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftAcceptRequest {
|
||||||
|
name: string
|
||||||
|
category_id: string
|
||||||
|
description?: string | null
|
||||||
|
parameters_schema: { parameters: Array<Record<string, unknown>> } | Record<string, unknown>
|
||||||
|
edited_body?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftAcceptResponse {
|
||||||
|
draft_id: string
|
||||||
|
promoted_template_id: string
|
||||||
|
template_slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftRejectResponse {
|
||||||
|
draft_id: string
|
||||||
|
status: 'rejected'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const draftTemplatesApi = {
|
||||||
|
async list(pendingOnly = true): Promise<DraftTemplate[]> {
|
||||||
|
const r = await apiClient.get<{ drafts: DraftTemplate[] }>('/draft-templates', {
|
||||||
|
params: { pending_only: pendingOnly },
|
||||||
|
})
|
||||||
|
return r.data.drafts
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<DraftTemplate> {
|
||||||
|
const r = await apiClient.get<DraftTemplate>(`/draft-templates/${id}`)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async accept(id: string, data: DraftAcceptRequest): Promise<DraftAcceptResponse> {
|
||||||
|
const r = await apiClient.post<DraftAcceptResponse>(
|
||||||
|
`/draft-templates/${id}/accept`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async reject(id: string): Promise<DraftRejectResponse> {
|
||||||
|
const r = await apiClient.post<DraftRejectResponse>(
|
||||||
|
`/draft-templates/${id}/reject`,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account preferences (used by the "don't ask again" opt-out) ────────────
|
||||||
|
|
||||||
|
export interface AccountPreferences {
|
||||||
|
preferences: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountPreferencesApi = {
|
||||||
|
async get(): Promise<AccountPreferences> {
|
||||||
|
const r = await apiClient.get<AccountPreferences>('/accounts/me/preferences')
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(patch: Record<string, unknown>): Promise<AccountPreferences> {
|
||||||
|
const r = await apiClient.patch<AccountPreferences>('/accounts/me/preferences', {
|
||||||
|
preferences: patch,
|
||||||
|
})
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default draftTemplatesApi
|
||||||
@@ -37,3 +37,4 @@ export { handoffsApi } from './handoffs'
|
|||||||
export { resolutionsApi } from './resolutions'
|
export { resolutionsApi } from './resolutions'
|
||||||
export { deviceTypesApi } from './deviceTypes'
|
export { deviceTypesApi } from './deviceTypes'
|
||||||
export { networkDiagramsApi } from './networkDiagrams'
|
export { networkDiagramsApi } from './networkDiagrams'
|
||||||
|
export { ticketsApi } from './tickets'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
||||||
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
import type { PSABoard, TicketLinkResponse, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||||
|
import type { TicketListResponse } from '@/types/tickets'
|
||||||
|
|
||||||
export const integrationsApi = {
|
export const integrationsApi = {
|
||||||
getConnection: () =>
|
getConnection: () =>
|
||||||
@@ -15,20 +16,22 @@ export const integrationsApi = {
|
|||||||
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
||||||
listBoards: () =>
|
listBoards: () =>
|
||||||
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
|
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
|
||||||
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
|
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }): Promise<TicketListResponse> =>
|
||||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||||
searchTicketsQueue: (params: {
|
searchTicketsQueue: (params: {
|
||||||
assigned_to_me?: boolean
|
assigned_to_me?: boolean
|
||||||
unassigned?: boolean
|
unassigned?: boolean
|
||||||
board_ids?: string
|
board_ids?: string
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
}) =>
|
}): Promise<TicketListResponse> =>
|
||||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||||
getTicket: (id: string) =>
|
getTicket: (id: string) =>
|
||||||
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
||||||
getTicketStatuses: (ticketId: string) =>
|
getTicketStatuses: (ticketId: string) =>
|
||||||
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data),
|
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data),
|
||||||
|
getBoardStatuses: (boardId: number | string) =>
|
||||||
|
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/boards/${boardId}/statuses`).then(r => r.data),
|
||||||
listMembers: () =>
|
listMembers: () =>
|
||||||
apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data),
|
apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data),
|
||||||
getMemberMappings: () =>
|
getMemberMappings: () =>
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ import type {
|
|||||||
} from '@/types'
|
} from '@/types'
|
||||||
import type { ScriptTemplateDetail } from '@/types'
|
import type { ScriptTemplateDetail } from '@/types'
|
||||||
|
|
||||||
|
export interface CreateSessionOptions {
|
||||||
|
origin?: 'standalone' | 'pilot_inline'
|
||||||
|
aiSessionId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const scriptBuilderApi = {
|
export const scriptBuilderApi = {
|
||||||
async createSession(language: string): Promise<ScriptBuilderSessionDetail> {
|
async createSession(
|
||||||
const { data } = await apiClient.post('/scripts/builder/sessions', { language })
|
language: string,
|
||||||
|
options?: CreateSessionOptions,
|
||||||
|
): Promise<ScriptBuilderSessionDetail> {
|
||||||
|
const { data } = await apiClient.post('/scripts/builder/sessions', {
|
||||||
|
language,
|
||||||
|
origin: options?.origin,
|
||||||
|
ai_session_id: options?.aiSessionId,
|
||||||
|
})
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
89
frontend/src/api/sessionFacts.ts
Normal file
89
frontend/src/api/sessionFacts.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Session facts API — the "What we know" CRUD surface for a FlowPilot session.
|
||||||
|
*
|
||||||
|
* Mirrors backend endpoints at `/api/v1/ai-sessions/{id}/facts`.
|
||||||
|
* See FLOWPILOT-MIGRATION.md Section 5.1.
|
||||||
|
*/
|
||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export type SessionFactSourceType =
|
||||||
|
| 'question'
|
||||||
|
| 'diagnostic_check'
|
||||||
|
| 'user_note'
|
||||||
|
| 'ai_synthesis'
|
||||||
|
|
||||||
|
export interface SessionFact {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
text: string
|
||||||
|
source_type: SessionFactSourceType
|
||||||
|
source_ref: string | null
|
||||||
|
source_summary: string | null
|
||||||
|
created_by: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
// Server-computed: false for question/diagnostic_check (PATCH returns 403),
|
||||||
|
// true for user_note/ai_synthesis. Drives the edit affordance in the UI.
|
||||||
|
editable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionFactCreateRequest {
|
||||||
|
text: string
|
||||||
|
summary?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionFactUpdateRequest {
|
||||||
|
text?: string | null
|
||||||
|
summary?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionFactPromoteRequest {
|
||||||
|
source_type: 'question' | 'diagnostic_check' | 'ai_synthesis'
|
||||||
|
source_ref?: string | null
|
||||||
|
proposed_text?: string | null
|
||||||
|
proposed_summary?: string | null
|
||||||
|
raw_input?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionFactsApi = {
|
||||||
|
async list(sessionId: string): Promise<SessionFact[]> {
|
||||||
|
const r = await apiClient.get<{ facts: SessionFact[] }>(
|
||||||
|
`/ai-sessions/${sessionId}/facts`,
|
||||||
|
)
|
||||||
|
return r.data.facts
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(sessionId: string, data: SessionFactCreateRequest): Promise<SessionFact> {
|
||||||
|
const r = await apiClient.post<SessionFact>(
|
||||||
|
`/ai-sessions/${sessionId}/facts`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(
|
||||||
|
sessionId: string,
|
||||||
|
factId: string,
|
||||||
|
data: SessionFactUpdateRequest,
|
||||||
|
): Promise<SessionFact> {
|
||||||
|
const r = await apiClient.patch<SessionFact>(
|
||||||
|
`/ai-sessions/${sessionId}/facts/${factId}`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(sessionId: string, factId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/ai-sessions/${sessionId}/facts/${factId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async promote(sessionId: string, data: SessionFactPromoteRequest): Promise<SessionFact> {
|
||||||
|
const r = await apiClient.post<SessionFact>(
|
||||||
|
`/ai-sessions/${sessionId}/facts/promote`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return r.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default sessionFactsApi
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user