diff --git a/.gitignore b/.gitignore index 7a3d7918..caa3f59a 100644 --- a/.gitignore +++ b/.gitignore @@ -233,6 +233,7 @@ package.json package-lock.json .worktrees/ .gstack/ +.gitnexus # graphify knowledge graph outputs graphify-out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fde67b..e8cffb99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,44 @@ All notable changes to ResolutionFlow are documented here. - Tree Templates + Import/Export marketplace (#66) - Recurring Issue Detection — client-specific pattern alerts (#60) - Step Feedback Flag — "This Step is Wrong" reporting (#58) +- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library) +- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items +- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance + +### Changed +- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy +- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view +- **Account settings page** — audit fixes for improved consistency and usability +- **PSA documentation formatting** — removed duplicate timing blocks and AI confidence sections; added client-facing communication context guidance +- **Status update generation** — fixed option label lookup to use human-readable labels instead of machine values + +### Fixed +- Dark text rendering on blue accent step-number badges across all flow types +- Script Library tab ownership filter now preserved across category and search changes +- Race conditions in script builder session creation and slug generation +- Stale async results in Assistant Chat (selectChat) no longer clobber new session task lane +- Sentry DSN hardcoded fallback removed — now uses environment variable only +- Option label resolution in status update context generation + +--- + +## [0.11.0] - 2026-03-30 + +### Changed +- **Landing page redesign** — replaced AI-template layout with bold hero, live chat animation, scroll-driven reveals, and FAQ section; self-contained `--lp-*` palette; electric blue accent throughout +- **Dashboard design critique** — eliminated section redundancy, differentiated card types across PerformanceCards, KnowledgeBaseCards, and TeamSummary; reduced visual noise +- **Session History** — redesigned as tabbed view (AI Sessions / Flow Sessions) with Load More pagination and domain filter chips; AI sessions now support lazy-loaded flow sessions with URL param routing to correct tab +- **Escalation Queue** — improved urgency signaling with time-based styling +- **Assistant page** — TaskLane UX improvements (confirmed-delete, restorable skipped tasks, progress counter); ChatSidebar delete confirmation flow fixed (no accidental chat switch while confirming) +- **Script Library/Builder** — design critique fixes; suggestion chips now correctly respect disabled state during generation +- **Create Flow dropdown** — simplified to two options (Troubleshooting / Procedural); removed AI generate flow and maintenance flow per pilot scope +- **Tag badges and buttons** — fixed unreadable text caused by `bg-accent` with dark foreground; tags now use elevated background with border + +### Fixed +- Restored removed icon imports in MyTreesPage; added default export to SessionHistoryPage +- Fixed ternary closing brackets in KnowledgeBaseCards and TeamSummary +- Fixed `loadMoreAiSessions` race condition — stale pages from prior filter queries no longer mix with fresh results +- Fixed `--lp-btn` using `var(--color-accent)` in `landing.css` (violates lesson 104); now hardcoded to `#60a5fa` --- diff --git a/CLAUDE.md b/CLAUDE.md index 25af73c8..4ce6373a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md - Patherly / ResolutionFlow Project Context -> **Last Updated:** March 27, 2026 +> **Last Updated:** April 6, 2026 --- @@ -16,7 +16,8 @@ | Context | Name Used | |---------|-----------| -| Repository / directory / database / Docker | `patherly` / `patherly_postgres` | +| Repository / directory / database | `patherly` (internal name) | +| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` | | Backend, frontend UI, production URLs | **ResolutionFlow** | - **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions @@ -44,7 +45,7 @@ - **Phase:** Go-to-Market Validation (Pre-PMF) - **Backend:** Complete (55+ API endpoints, 100+ integration tests) - **Frontend:** Core features complete, Tree Editor functional -- **Database:** PostgreSQL with Docker, 98 migrations +- **Database:** PostgreSQL with Docker, 101 migrations - **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md) ### What's In Progress @@ -96,7 +97,7 @@ patherly/ │ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals │ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis │ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection -│ ├── alembic/ # Database migrations (001-029+) +│ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs) │ ├── scripts/ # seed_data.py, seed_trees.py │ └── tests/ # pytest integration tests ├── frontend/ @@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/` ## Development Commands ```powershell -# Start PostgreSQL -docker start patherly_postgres +# 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 @@ -203,21 +204,19 @@ npm run dev pytest --override-ini="addopts=" # First time only: create test database -docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;" +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" --rev-id=NNN # NNN = next sequential number -# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069). -# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1 -# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py). -# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations. +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 -docker exec -it patherly_postgres psql -U postgres -d patherly +# 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 @@ -292,7 +291,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`. +**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. @@ -332,7 +331,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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 is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance. +**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. @@ -344,6 +343,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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. @@ -353,7 +353,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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 ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. 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. Orange is reserved for interactive elements only (buttons, active nav, links). +**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. @@ -369,7 +369,11 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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`). ## RBAC & Permissions @@ -386,16 +390,16 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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 planned. -- **Backgrounds:** `bg-page` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`) -- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`) -- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated` -- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim` -- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color. -- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`) -- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity -- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74` -- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, or cyan accent (`#22d3ee`) +- **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 --- @@ -514,3 +518,105 @@ When a feature, fix, or significant piece of work is finished and merged/committ | 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 — Code Intelligence + +This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness | +| `gitnexus://repo/resolutionflow/clusters` | All functional areas | +| `gitnexus://repo/resolutionflow/processes` | All execution flows | +| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace | + +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/backend/alembic/versions/070_add_unique_constraint_script_template_slug.py b/backend/alembic/versions/070_add_unique_constraint_script_template_slug.py new file mode 100644 index 00000000..2fad3b09 --- /dev/null +++ b/backend/alembic/versions/070_add_unique_constraint_script_template_slug.py @@ -0,0 +1,29 @@ +"""add unique constraint to script_templates.slug + +Revision ID: 070 +Revises: 069 +Create Date: 2026-04-01 +""" +from alembic import op + + +revision = "070" +down_revision = "069" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_unique_constraint( + "uq_script_templates_slug", + "script_templates", + ["slug"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "uq_script_templates_slug", + "script_templates", + type_="unique", + ) diff --git a/backend/app/api/endpoints/script_builder.py b/backend/app/api/endpoints/script_builder.py index b328477c..f56c595c 100644 --- a/backend/app/api/endpoints/script_builder.py +++ b/backend/app/api/endpoints/script_builder.py @@ -3,6 +3,7 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -67,6 +68,12 @@ async def create_session( current_user: Annotated[User, Depends(get_current_active_user)], ) -> ScriptBuilderSessionDetail: """Start a new Script Builder session.""" + # Acquire per-user advisory lock so concurrent create requests are serialized. + # Without this, two simultaneous requests both read count < limit and both + # insert, exceeding MAX_SESSIONS_PER_USER. + user_lock_key = hash(str(current_user.id)) % (2**62) + await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key}) + # Enforce max concurrent sessions count = await script_builder_service.count_user_sessions(db, current_user.id) if count >= MAX_SESSIONS_PER_USER: diff --git a/backend/app/models/script_template.py b/backend/app/models/script_template.py index 0ea71ea8..838d2f3c 100644 --- a/backend/app/models/script_template.py +++ b/backend/app/models/script_template.py @@ -48,7 +48,7 @@ class ScriptTemplate(Base): UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) name: Mapped[str] = mapped_column(String(200), nullable=False) - slug: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True) script_body: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index b66afbbf..ef42359f 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -127,6 +127,7 @@ class SessionDocumentation(BaseModel): diagnostic_steps: list[DocumentationStep] resolution_summary: str | None = None escalation_reason: str | None = None + follow_up_recommendations: list[str] = [] total_steps: int duration_display: str | None = None generated_at: datetime @@ -146,7 +147,7 @@ class StatusUpdateRequest(BaseModel): """Generate a mid-session or post-session status update.""" audience: str = Field( ..., - pattern="^(ticket_notes|client_update|email_draft)$", + pattern="^(ticket_notes|client_update|email_draft|request_info)$", description="Who is this update for?", ) length: str = Field( diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 49c44ffe..6e17b8e3 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -77,6 +77,9 @@ scope narrows it to this endpoint. - JSON array of objects with `text` (required) and `context` (optional, 1 sentence) - 1-3 questions per response - Do NOT ask questions inline in your prose. ALL questions go in the marker. +- If the engineer's message contains tasks marked `_(not yet completed)_`, re-include \ +those as questions/actions in your next response UNLESS you are ≥75% confident the \ +information is no longer needed to resolve the issue. Default to keeping them. **[ACTIONS] marker format:** - JSON array of objects with `label` (required), `command` (optional), `description` (required) @@ -155,6 +158,8 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers: Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ No exceptions. Not even when forking. A response without at least one of these markers \ will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional. +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. """ diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index 3a9a6f78..20c06299 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -911,16 +911,36 @@ async def generate_status_update( steps_summary = [] for step in sorted(session.steps, key=lambda s: s.step_order): content = step.content or {} - text = content.get("text", "") - response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None) + if content.get("type") in ("resolution_suggestion", "briefing", "status_update"): + continue + text = content.get("text", "").strip() + if not text: + continue + # Resolve option label instead of raw machine value + response = None + if step.was_skipped: + response = "Skipped" + elif step.selected_option and step.options_presented: + for opt in step.options_presented: + if opt.get("value") == step.selected_option: + response = opt.get("label", step.selected_option) + break + else: + response = step.selected_option + elif step.selected_option: + response = step.selected_option + elif step.free_text_input: + response = step.free_text_input outcome = None if step.action_result: outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve" - entry = f"Step {step.step_order + 1}: {text}" - if response: - entry += f"\n Engineer response: {response}" + entry = f"{step.step_order + 1}. {text}" + if response and response != "Skipped": + entry += f" — {response}" + elif response == "Skipped": + entry += " (skipped)" if outcome: - entry += f"\n Outcome: {outcome}" + entry += f" [{outcome}]" steps_summary.append(entry) steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet." @@ -929,13 +949,8 @@ async def generate_status_update( now = datetime.now(timezone.utc) ref_time = session.resolved_at or now delta = ref_time - session.created_at - total_minutes = int(delta.total_seconds() / 60) - if total_minutes < 60: - time_display = f"{total_minutes} minutes" - else: - hours = total_minutes // 60 - remaining = total_minutes % 60 - time_display = f"{hours}h {remaining}m" + total_hrs = round(delta.total_seconds() / 3600, 2) + time_display = f"{total_hrs} hrs" # Extract client name from intake or ticket data client_name = None @@ -1135,8 +1150,9 @@ def _build_status_update_prompt( Rules: - Be technical, concise, and factual -- Use markdown formatting (bold headers, bullet lists) -- Include: current status, steps completed, findings, what's been ruled out, next steps +- Use plain text with simple section headers (no markdown bold/bullets — PSA renders raw text) +- Structure as: current status paragraph, then "What We Know" section, then next steps +- "What We Know" should list confirmed findings, ruled-out causes, and open questions — keep each item to one line - Do NOT soften language or add pleasantries - Do NOT include greetings or sign-offs - {length_instruction} @@ -1147,28 +1163,54 @@ Output ONLY the update text. No JSON, no markdown code fences, no preamble.""" elif audience == "client_update": client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'" - return f"""You are generating a client-facing {context_label}. + context_guidance = { + "status": "We're actively working on it. Describe progress made so far and what comes next without giving a timeline.", + "resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.", + "escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.", + }.get(context, "") + return f"""You are generating a brief client-facing {context_label}. Rules: - Be professional, reassuring, and non-technical -- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.) -- NEVER include server names, IP addresses, internal tool names, or technical identifiers +- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", "connector", etc.) +- NEVER include server names, IP addresses, internal tool names, or ticket IDs - Explain findings in plain language a non-technical business owner would understand - {client_greeting} - Sign off with: {engineer_name} - {length_instruction} -{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""} -{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""} +- {context_guidance} Output ONLY the update text. No JSON, no markdown code fences, no preamble.""" + elif audience == "request_info": + client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'" + return f"""You are generating a brief, professional message requesting information from the client. + +Rules: +- Be friendly, concise, and non-technical +- Start with one sentence explaining what you're currently working on (plain language, no jargon) +- Then list the specific questions you need answered, as a numbered list +- Each question should be clear and answerable by a non-technical user +- NEVER use technical jargon, server names, IP addresses, or internal tool names +- {client_greeting} +- Sign off with: {engineer_name} +- Keep it short — this is a targeted ask, not a status update + +Output ONLY the message text. No JSON, no markdown code fences, no preamble.""" + else: # email_draft client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'" subject_hints = { "status": "Update: [brief issue description]", "resolution": "Resolved: [brief issue description]", - "escalation": "Update: [brief issue description] — Specialist Review", + "escalation": "Update: [brief issue description] — Specialist Assistance", + "need_info": "Quick Question: [brief issue description]", } + context_guidance = { + "status": "We're actively working on it. Describe progress and next steps without giving a timeline.", + "resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.", + "escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.", + }.get(context, "") return f"""You are generating a complete email draft for client communication. Rules: @@ -1177,15 +1219,63 @@ Rules: - {client_greeting} - Be professional, reassuring, and non-technical - NEVER use technical jargon, server names, IP addresses, or internal tool names -- Include a professional sign-off with: - {engineer_name} +- Include a professional sign-off with: {engineer_name} - {length_instruction} -{"- This is good news — the issue is resolved." if context == "resolution" else ""} -{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""} +- {context_guidance} Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble.""" +def _build_what_we_know(session: AISession) -> str: + """Build a 'What We Know' summary from evidence_items (cockpit) or derived from steps. + + When the cockpit branch merges, session.evidence_items will be populated by the AI + with confirmed/ruled_out/pending classifications. Until then, we derive findings + from completed diagnostic steps. + """ + evidence_items = getattr(session, 'evidence_items', None) + if evidence_items: + confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed'] + ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out'] + pending = [e['text'] for e in evidence_items if e.get('status') == 'pending'] + parts = [] + if confirmed: + parts.append("Confirmed:\n" + "\n".join(f" - {t}" for t in confirmed)) + if ruled_out: + parts.append("Ruled out:\n" + "\n".join(f" - {t}" for t in ruled_out)) + if pending: + parts.append("Still investigating:\n" + "\n".join(f" - {t}" for t in pending)) + return "\n".join(parts) + + # Derive from completed steps + findings = [] + for step in sorted(session.steps or [], key=lambda s: s.step_order): + content = step.content or {} + if content.get("type") in ("resolution_suggestion", "briefing", "status_update"): + continue + description = content.get("text", "").strip() + if not description or step.was_skipped: + continue + response = None + if step.selected_option and step.options_presented: + for opt in step.options_presented: + if opt.get("value") == step.selected_option: + response = opt.get("label", step.selected_option) + break + else: + response = step.selected_option + elif step.selected_option: + response = step.selected_option + elif step.free_text_input: + response = step.free_text_input + if response: + findings.append(f"{description} — {response}") + + if not findings: + return "" + return "Findings so far:\n" + "\n".join(f" - {f}" for f in findings) + + def _build_status_update_context( session: AISession, steps_text: str, @@ -1206,24 +1296,17 @@ def _build_status_update_context( if session.psa_ticket_id: parts.append(f"Ticket ID: {session.psa_ticket_id}") - parts.append(f"\nDiagnostic steps:\n{steps_text}") + what_we_know = _build_what_we_know(session) + if what_we_know: + parts.append(f"\nWhat we know:\n{what_we_know}") + + parts.append(f"\nDiagnostic steps taken:\n{steps_text}") if context == "resolution" and session.resolution_summary: parts.append(f"\nResolution: {session.resolution_summary}") if context == "escalation" and session.escalation_reason: parts.append(f"\nEscalation reason: {session.escalation_reason}") - # Include recent conversation messages for richer context - messages = session.conversation_messages or [] - if messages: - recent = messages[-10:] # Last 10 messages - convo_text = "\n".join( - f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}" - for m in recent - if isinstance(m, dict) and "role" in m and "content" in m - ) - parts.append(f"\nRecent conversation:\n{convo_text}") - return "\n".join(parts) @@ -1420,6 +1503,7 @@ def _create_step_from_parsed( def _generate_documentation(session: AISession) -> SessionDocumentation: """Generate structured documentation from a session's steps.""" diagnostic_steps = [] + follow_up_recommendations: list[str] = [] for step in session.steps: content = step.content or {} @@ -1459,6 +1543,12 @@ def _generate_documentation(session: AISession) -> SessionDocumentation: outcome=outcome, )) + # Collect follow-up recommendations from resolution suggestion steps + if content.get("type") == "resolution_suggestion": + recs = content.get("follow_up_recommendations", []) + if isinstance(recs, list): + follow_up_recommendations.extend(recs) + # Calculate duration duration_display = None if session.resolved_at and session.created_at: @@ -1484,6 +1574,7 @@ def _generate_documentation(session: AISession) -> SessionDocumentation: diagnostic_steps=diagnostic_steps, resolution_summary=session.resolution_summary, escalation_reason=session.escalation_reason, + follow_up_recommendations=follow_up_recommendations, total_steps=session.step_count, duration_display=duration_display, generated_at=datetime.now(timezone.utc), diff --git a/backend/app/services/psa_documentation_service.py b/backend/app/services/psa_documentation_service.py index 6a40bbf5..17a62587 100644 --- a/backend/app/services/psa_documentation_service.py +++ b/backend/app/services/psa_documentation_service.py @@ -57,181 +57,199 @@ def _format_datetime(dt: datetime | None) -> str: return dt.strftime("%Y-%m-%d %I:%M %p UTC") +def _get_engineer_response(step) -> str | None: + """Extract the engineer's response label from a step.""" + if step.was_skipped: + return "Skipped" + if step.selected_option and step.options_presented: + for opt in step.options_presented: + if opt.get("value") == step.selected_option: + return opt.get("label", step.selected_option) + return step.selected_option + if step.selected_option: + return step.selected_option + if step.free_text_input: + return step.free_text_input + return None + + def format_resolution_note(session: AISession, include_steps: bool = True) -> str: """Format a resolved session as a plain-text note for CW.""" - lines = [ - "═══ FlowPilot Session Documentation ═══", - f"Session: {session.id}", - ] - - # Engineer name from relationship if loaded, otherwise user_id engineer_name = getattr(session, 'user', None) - if engineer_name and hasattr(engineer_name, 'name'): - lines.append(f"Engineer: {engineer_name.name}") + engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown" - lines.extend([ - f"Date: {_format_datetime(session.resolved_at)}", - f"Started: {_format_datetime(session.created_at)}", - f"Ended: {_format_datetime(session.resolved_at)}", - ]) - - # Duration + duration_str = "" if session.resolved_at and session.created_at: delta = session.resolved_at - session.created_at - minutes = int(delta.total_seconds() / 60) - if minutes < 60: - lines.append(f"Duration: {minutes}m") - else: - lines.append(f"Duration: {minutes // 60}h {minutes % 60}m") + total_hrs = round(delta.total_seconds() / 3600, 2) + duration_str = f" — {total_hrs} hrs" - lines.append("") - lines.append("── Problem ──") - lines.append(session.problem_summary or "No summary available") - if session.problem_domain: - lines.append(f"Domain: {session.problem_domain}") + lines = [ + f"FlowPilot Session — {engineer_display}{duration_str}", + f"Problem: {session.problem_summary or 'No summary available'}", + ] # Diagnostic steps if include_steps and session.steps: lines.append("") - lines.append("── Diagnosis Path ──") + lines.append("Steps:") for step in session.steps: content = step.content or {} - step_type = content.get("type", step.step_type).capitalize() - description = content.get("text", "") - - response_text = "" - if step.was_skipped: - response_text = "Skipped" - elif step.selected_option: - # Try to find the label - if step.options_presented: - for opt in step.options_presented: - if opt.get("value") == step.selected_option: - response_text = opt.get("label", step.selected_option) - break - else: - response_text = step.selected_option - else: - response_text = step.selected_option - elif step.free_text_input: - response_text = step.free_text_input - - lines.append(f"{step.step_order + 1}. [{step_type}] {description}") - if response_text: - lines.append(f" → Response: {response_text}") - if step.action_result: - result = step.action_result - outcome = "Succeeded" if result.get("success") else "Did not resolve" - if details := result.get("details"): - outcome += f" — {details}" - lines.append(f" → Result: {outcome}") + step_type = content.get("type", "") + if step_type == "resolution_suggestion": + continue # Not a diagnostic step + description = content.get("text", "").strip() + if not description: + continue + response = _get_engineer_response(step) + line = f"{step.step_order + 1}. {description}" + if response and response != "Skipped": + line += f" — {response}" + elif response == "Skipped": + line += " (skipped)" + lines.append(line) # Resolution lines.append("") - lines.append("── Resolution ──") - lines.append(session.resolution_summary or "No resolution summary") + lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}") if session.resolution_action: lines.append(session.resolution_action) - # Confidence - lines.append("") - lines.append("── AI Confidence ──") - lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})") + # Follow-up recommendations from resolution suggestion step + follow_ups: list[str] = [] + for step in session.steps: + content = step.content or {} + if content.get("type") == "resolution_suggestion": + recs = content.get("follow_up_recommendations", []) + if isinstance(recs, list): + follow_ups.extend(recs) + if follow_ups: + lines.append("") + lines.append("Follow-up:") + for rec in follow_ups: + lines.append(f"- {rec}") - # Timing section (always present) + # Timing lines.append("") - lines.append("── Session Timing ──") lines.append(f"Start: {_format_datetime(session.created_at)}") lines.append(f"End: {_format_datetime(session.resolved_at)}") if session.resolved_at and session.created_at: delta = session.resolved_at - session.created_at - minutes = int(delta.total_seconds() / 60) - lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m") + total_hrs = round(delta.total_seconds() / 3600, 2) + lines.append(f"Total: {total_hrs} hrs") lines.append("") - lines.append("Generated by ResolutionFlow FlowPilot") + lines.append("Generated by ResolutionFlow") return "\n".join(lines) +def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]: + """Return (confirmed, ruled_out, pending) findings. + + Uses session.evidence_items when the cockpit branch is merged; falls back + to deriving from completed diagnostic steps. + """ + evidence_items = getattr(session, 'evidence_items', None) + if evidence_items: + confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed'] + ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out'] + pending = [e['text'] for e in evidence_items if e.get('status') == 'pending'] + return confirmed, ruled_out, pending + + # Derive from completed steps — all answered steps become findings + findings = [] + for step in sorted(session.steps or [], key=lambda s: s.step_order): + content = step.content or {} + if content.get("type") in ("resolution_suggestion", "briefing", "status_update"): + continue + description = content.get("text", "").strip() + if not description or step.was_skipped: + continue + response = _get_engineer_response(step) + if response: + findings.append(f"{description} — {response}") + return findings, [], [] + + def format_escalation_note(session: AISession, include_steps: bool = True) -> str: """Format an escalated session as a plain-text note for CW.""" + engineer_obj = getattr(session, 'user', None) + engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown" + + escalated_to_obj = getattr(session, 'escalated_to', None) + escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None + + escalated_at = session.resolved_at or datetime.now(timezone.utc) + duration_str = "" + if session.created_at: + delta = escalated_at - session.created_at + total_hrs = round(delta.total_seconds() / 3600, 2) + duration_str = f" — {total_hrs} hrs" + + header = f"FlowPilot Escalation — {engineer_display}{duration_str}" + if escalated_to_display: + header += f" → {escalated_to_display}" lines = [ - "═══ FlowPilot Escalation Documentation ═══", - f"Session: {session.id}", + header, + f"Problem: {session.problem_summary or 'No summary available'}", ] - engineer_name = getattr(session, 'user', None) - if engineer_name and hasattr(engineer_name, 'name'): - lines.append(f"Escalated by: {engineer_name.name}") - - escalated_to = getattr(session, 'escalated_to', None) - if escalated_to and hasattr(escalated_to, 'name'): - lines.append(f"Escalated to: {escalated_to.name}") - else: - lines.append("Escalated to: Unassigned") - - lines.extend([ - f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}", - f"Started: {_format_datetime(session.created_at)}", - ]) - - if session.resolved_at and session.created_at: - delta = session.resolved_at - session.created_at - minutes = int(delta.total_seconds() / 60) - lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m") - - lines.append("") - lines.append("── Problem ──") - lines.append(session.problem_summary or "No summary available") - - # Work completed + # Work completed with responses if include_steps and session.steps: lines.append("") - lines.append("── Work Completed ──") - for step in session.steps: + lines.append("Work completed:") + for step in sorted(session.steps, key=lambda s: s.step_order): content = step.content or {} - description = content.get("text", "") - lines.append(f"{step.step_order + 1}. {description}") + if content.get("type") in ("resolution_suggestion", "briefing", "status_update"): + continue + description = content.get("text", "").strip() + if not description: + continue + response = _get_engineer_response(step) + line = f"{step.step_order + 1}. {description}" + if response and response != "Skipped": + line += f" — {response}" + elif response == "Skipped": + line += " (skipped)" + lines.append(line) + + # What We Know + confirmed, ruled_out, pending = _derive_what_we_know(session) + if confirmed or ruled_out or pending: + lines.append("") + lines.append("What we know:") + for f in confirmed: + lines.append(f" ✓ {f}") + for f in ruled_out: + lines.append(f" ✗ {f}") + for f in pending: + lines.append(f" ? {f}") # Escalation reason lines.append("") - lines.append("── Escalation Reason ──") - lines.append(session.escalation_reason or "No reason provided") + lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}") - # Escalation package details + # Suggested next steps from escalation package pkg = session.escalation_package or {} - if hypotheses := pkg.get("remaining_hypotheses"): - lines.append("") - lines.append("── Remaining Hypotheses ──") - if isinstance(hypotheses, list): - for h in hypotheses: - lines.append(f"- {h}") - else: - lines.append(str(hypotheses)) - if suggestions := pkg.get("suggested_next_steps"): lines.append("") - lines.append("── Suggested Next Steps ──") - if isinstance(suggestions, list): - for s in suggestions: - lines.append(f"- {s}") - else: - lines.append(str(suggestions)) + lines.append("Suggested next steps:") + items = suggestions if isinstance(suggestions, list) else [str(suggestions)] + for s in items: + lines.append(f"- {s}") # Timing lines.append("") - lines.append("── Session Timing ──") lines.append(f"Start: {_format_datetime(session.created_at)}") - escalated_at = session.resolved_at or datetime.now(timezone.utc) lines.append(f"Escalated: {_format_datetime(escalated_at)}") if session.created_at: delta = escalated_at - session.created_at - minutes = int(delta.total_seconds() / 60) - lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m") + total_hrs = round(delta.total_seconds() / 3600, 2) + lines.append(f"Total: {total_hrs} hrs") lines.append("") - lines.append("Generated by ResolutionFlow FlowPilot") + lines.append("Generated by ResolutionFlow") return "\n".join(lines) diff --git a/backend/app/services/resolution_output_generator.py b/backend/app/services/resolution_output_generator.py index 4d8f3e3d..1b317d5c 100644 --- a/backend/app/services/resolution_output_generator.py +++ b/backend/app/services/resolution_output_generator.py @@ -83,19 +83,55 @@ class ResolutionOutputGenerator: return output def _build_session_context(self, session: AISession) -> str: + intake = session.intake_content or {} + intake_text = intake.get("text", "") or str(intake) parts = [ f"Problem: {session.problem_summary or 'Unknown'}", f"Domain: {session.problem_domain or 'Unknown'}", + f"Original intake: {intake_text[:300]}", f"Resolution: {session.resolution_summary or 'Not specified'}", - f"Steps taken: {session.step_count}", ] - msgs = session.conversation_messages or [] - if msgs: - parts.append("\nConversation highlights:") - for msg in msgs[-10:]: - role = msg.get("role", "unknown") - content = msg.get("content", "")[:200] - parts.append(f" [{role}]: {content}") + + steps = sorted(session.steps or [], key=lambda s: s.step_order) + diagnostic = [] + follow_ups: list[str] = [] + for step in steps: + content = step.content or {} + step_type = content.get("type", "") + if step_type == "resolution_suggestion": + recs = content.get("follow_up_recommendations", []) + if isinstance(recs, list): + follow_ups.extend(recs) + continue + description = content.get("text", "").strip() + if not description: + continue + response = None + if step.was_skipped: + response = "skipped" + elif step.selected_option and step.options_presented: + for opt in step.options_presented: + if opt.get("value") == step.selected_option: + response = opt.get("label", step.selected_option) + break + else: + response = step.selected_option + elif step.selected_option: + response = step.selected_option + elif step.free_text_input: + response = step.free_text_input + entry = f" {step.step_order + 1}. {description}" + if response and response != "skipped": + entry += f" — {response}" + diagnostic.append(entry) + + if diagnostic: + parts.append("\nDiagnostic steps:") + parts.extend(diagnostic) + if follow_ups: + parts.append("\nRecommended follow-up:") + parts.extend(f" - {r}" for r in follow_ups) + return "\n".join(parts) def _psa_notes_prompt(self, context: str) -> str: diff --git a/backend/app/services/script_builder_service.py b/backend/app/services/script_builder_service.py index b483f39e..a1a7647e 100644 --- a/backend/app/services/script_builder_service.py +++ b/backend/app/services/script_builder_service.py @@ -5,7 +5,8 @@ from datetime import datetime, timezone from typing import Optional from uuid import UUID -from sqlalchemy import select, func +from sqlalchemy import select, func, text +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -169,6 +170,12 @@ async def send_message( user_content: str, ) -> ScriptBuilderMessageResponse: """Send a user message and get AI response with generated script.""" + # Acquire per-session advisory lock to prevent concurrent message count races. + # Two simultaneous sends to the same session would otherwise both read the same + # count, both pass the limit check, and both insert — exceeding the cap. + session_lock_key = hash(str(session.id)) % (2**62) + await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": session_lock_key}) + # Count existing messages for the session msg_count_result = await db.execute( select(func.count(ScriptBuilderMessage.id)).where( @@ -344,36 +351,48 @@ async def save_to_library( raise ValueError("Default 'AI Generated' category not found. Run migrations.") resolved_category_id = default_cat - # Generate unique slug + # Generate slug. Use a UUID suffix on first attempt to prevent concurrent + # saves with the same name from hitting the unique constraint on slug. base_slug = name.lower().replace(" ", "-").replace("_", "-")[:80] base_slug = re.sub(r"[^a-z0-9\-]", "", base_slug) - slug = base_slug - # Check uniqueness - existing = await db.execute( - select(ScriptTemplate.id).where(ScriptTemplate.slug == slug) - ) - if existing.scalar_one_or_none(): - slug = f"{base_slug}-{uuid_mod.uuid4().hex[:6]}" - template = ScriptTemplate( - id=uuid_mod.uuid4(), - category_id=resolved_category_id, - created_by=user_id, - team_id=team_id if share_with_team else None, - name=name, - slug=slug, - description=description, - script_body=script_body or session.latest_script, - parameters_schema=parameters_schema or {"parameters": []}, - default_values={}, - validation_rules={}, - tags=[session.language, "ai-generated"], - complexity="intermediate", - is_verified=False, - is_active=True, - version=1, - usage_count=0, + # Check if the base slug is already taken; if not, use it clean (no suffix). + # If taken, or if the insert races with a concurrent request, retry with a + # fresh UUID suffix. The unique constraint on script_templates.slug is the + # authoritative guard — the application check just avoids unnecessary retries. + existing = await db.execute( + select(ScriptTemplate.id).where(ScriptTemplate.slug == base_slug) ) - db.add(template) - await db.flush() - return template + slug = base_slug if not existing.scalar_one_or_none() else f"{base_slug}-{uuid_mod.uuid4().hex[:6]}" + + for attempt in range(3): + template = ScriptTemplate( + id=uuid_mod.uuid4(), + category_id=resolved_category_id, + created_by=user_id, + team_id=team_id if share_with_team else None, + name=name, + slug=slug, + description=description, + script_body=script_body or session.latest_script, + parameters_schema=parameters_schema or {"parameters": []}, + default_values={}, + validation_rules={}, + tags=[session.language, "ai-generated"], + complexity="intermediate", + is_verified=False, + is_active=True, + version=1, + usage_count=0, + ) + db.add(template) + try: + await db.flush() + return template + except IntegrityError as exc: + if "uq_script_templates_slug" not in str(exc.orig) or attempt == 2: + raise + await db.rollback() + slug = f"{base_slug}-{uuid_mod.uuid4().hex[:8]}" + + raise RuntimeError("Failed to generate a unique slug after 3 attempts") diff --git a/docs/cockpit/2026-04-01-msp-assistant-harness-design.md b/docs/cockpit/2026-04-01-msp-assistant-harness-design.md new file mode 100644 index 00000000..66ae9c69 --- /dev/null +++ b/docs/cockpit/2026-04-01-msp-assistant-harness-design.md @@ -0,0 +1,363 @@ +# MSP Assistant Harness — Design Spec +**Date:** 2026-04-01 +**Status:** Draft — pending user review +**Source:** MSP_Assistant_Harness_Implementation_Plan.docx (v2.0, March 2026) + brainstorming session + +--- + +## Context + +The `/assistant` page currently works as a generic AI chat surface with a task lane side panel and a chat sidebar for session history. It functions well but reads as "AI chat with extras" rather than an MSP engineer's operational tool. + +The goal is to reframe the page as a **live triage cockpit** — the place where an engineer opens a ticket, works through it from intake to resolution, and closes with a structured handoff artifact. The underlying session, branching, and chat architecture is preserved. What changes is layout hierarchy, information density, field labelling, and the conclude output. + +Scope is broader than the original docx: includes all required backend changes to support the frontend properly. + +--- + +## Design Decisions + +### 1. Overall Layout — Stacked Zones + +``` +┌─────────────────────────────────────────────┐ +│ Incident Header (labelled fields, 1 row) │ +├────────────────────────┬────────────────────┤ +│ │ FlowPilot Asks │ +│ Steps Checklist │ (quick replies) │ +│ (left, ~55%) ├────────────────────┤ +│ │ What We Know │ +│ │ (evidence list) │ +├────────────────────────┴────────────────────┤ ← drag handle +│ Conversation Log (muted, darker bg) │ +├─────────────────────────────────────────────┤ +│ Compose area │ +└─────────────────────────────────────────────┘ +``` + +- Work zone (top) and conversation log (bottom) are **drag-resizable** via a handle +- Default split: ~55% work zone, ~45% chat +- Existing left sidebar (session history) unchanged +- Compose area is always pinned to bottom, spans full width +- `workZoneHeight` persisted to `localStorage` so split survives refresh + +### 2. Incident Header + +Single row with explicit micro-labels above each field: + +``` +CLIENT DEVICE CATEGORY HYPOTHESIS +Contoso Ltd ✏ jsmith-desktop ✏ DNS / Network ✏ Corrupted DNS cache on NIC ✏ + [CW #48291] [Resolve ▾] [⋯] +``` + +- Each field has its own `✏` icon (visible on hover) that opens an inline edit popover +- Fields populate from: (a) intake form on session create, (b) AI-inferred updates mid-session via `triage_update`, (c) manual engineer edits via `PATCH /ai-sessions/{id}/triage` +- PSA ticket number shown if linked; action buttons (Resolve, overflow menu) on the right +- Empty fields show muted placeholder text — never blank + +### 3. Work Zone — Steps + FlowPilot Asks + What We Know + +**Left panel (~55%): ordered step checklist** +- Steps displayed as a vertical list: `✓` completed, `→` active (blue border, white text), `○` pending +- Active step is visually distinct +- "Generate Script" CTA appears at the bottom when a script-generation step is active + +**Right panel (~45%): two stacked mini-panels** +- **FlowPilot Asks** (top, amber label): current question from AI. When `options` are provided, renders as quick-reply buttons — clicking a button submits that answer as a chat message. When no `options`, renders a compact free-text input. Panel is empty/hidden when no pending question. +- **What We Know** (bottom, muted label): running evidence list. Each entry: `✓ confirmed` / `✗ ruled out` / `? pending`. AI appends via `triage_update.evidence_items`; engineer can manually add or edit entries. + +### 4. Conversation Log Zone + +- Lives below the work zone, separated by a **drag handle** +- Background: `#13151c` (one step darker than page) — visually recedes +- Label: "CONVERSATION LOG" in muted colour (`text-muted`) +- Messages are compact: `you:` / `fp:` prefixes instead of full name/avatar bubbles +- Scrolls independently +- Not collapsible — drag handle gives control + +### 5. Conclude / Handoff Modal (redesigned) + +On opening "Close Case": + +1. **Header**: "Close Case — [Client Name]" + outcome selector (Resolved / Escalated / Parked) +2. **Structured fields** — pre-filled by streaming `/handoff-draft`, all editable: + - **Root Cause** (short text input) + - **Resolution** (what fixed it) + - **Steps Taken** (list, auto-populated from step checklist) + - **Recommendations** (next steps / preventive actions) +3. **Output destinations** (checkboxes): Post to CW ticket note / Save to Knowledge Base / Send client summary +4. **Confirm** button — triggers resolve/escalate/pause and passes structured fields into `ResolutionOutputGenerator` + +The existing `SessionResolutionOutput` model and `ResolutionOutputGenerator` service are reused. The `/handoff-draft` stream starts immediately on modal open — the engineer can begin editing while fields fill in. + +--- + +## Backend Changes Required + +### 1. New AISession columns (Alembic migration) + +Add to `ai_sessions` table: + +| Column | Type | Purpose | +|--------|------|---------| +| `client_name` | `VARCHAR(255)` | MSP client name for incident header | +| `asset_name` | `VARCHAR(255)` | Device / asset / user being worked on | +| `issue_category` | `VARCHAR(100)` | Human-readable category (e.g. "DNS / Networking") | +| `triage_hypothesis` | `TEXT` | Current working hypothesis — AI-updated + engineer-editable | +| `evidence_items` | `JSONB` | "What We Know" list — persisted for session resume | + +`evidence_items` format: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]` + +Note: `problem_domain` (existing) is an internal classifier slug. `issue_category` is the human-readable display label for the header. Both coexist. + +### 2. New PATCH endpoint — triage metadata + +``` +PATCH /ai-sessions/{session_id}/triage +Auth: require_engineer_or_admin +Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? } +Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items } +``` + +Used when the engineer edits any header field or evidence list manually. + +### 3. Updated schemas — TriageUpdate and QuestionItem.options + +**New `TriageUpdate` model** (returned in chat response when AI infers session context): + +```python +class TriageUpdate(BaseModel): + client_name: str | None = None + asset_name: str | None = None + issue_category: str | None = None + triage_hypothesis: str | None = None + evidence_items: list[dict] | None = None # appends to existing list +``` + +**Updated `ChatMessageResponse`:** +```python +class ChatMessageResponse(BaseModel): + # existing fields unchanged... + triage_update: TriageUpdate | None = None +``` + +**Updated `QuestionItem`** — add `options` for quick-reply buttons: +```python +class QuestionItem(BaseModel): + text: str + context: str = "" + options: list[str] | None = None # quick-reply labels; null = free-text fallback +``` + +### 4. unified_chat_service.py — triage extraction + +After generating each AI response, run a lightweight extraction to populate `triage_update`. Implementation options (pick one during implementation): + +- **Option A (recommended):** Embed structured extraction in the system prompt using an `[TRIAGE_UPDATE]` marker, similar to existing `[QUESTIONS]` / `[ACTIONS]` markers. AI emits the block if it has new triage signals; service parses it. +- **Option B:** Post-response extraction pass using a fast model (`claude-haiku-4-5`) with the last 3 messages as context. + +When `triage_update` contains non-null fields, the service auto-PATCHes the session record (so fields are persisted) AND returns `triage_update` in the response for the frontend to update the header immediately. + +### 5. New streaming endpoint — handoff draft + +``` +POST /ai-sessions/{session_id}/handoff-draft +Auth: require_engineer_or_admin +Response: StreamingResponse (text/event-stream) +``` + +Streams a structured handoff JSON object: +```json +{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." } +``` + +Built from session context: `problem_summary`, `triage_hypothesis`, `evidence_items`, `conversation_messages` (last 20), step checklist from saved task lane state. + +### 6. Updated conclude schemas + +Add optional structured fields to `ResolveSessionRequest` and `EscalateSessionRequest`: + +```python +root_cause: str | None = None +steps_taken: list[str] | None = None +recommendations: str | None = None +``` + +Pass these into `ResolutionOutputGenerator._build_session_context()` to enrich `psa_ticket_notes` and `client_summary` outputs. + +### 7. Session read endpoint — include new triage fields + +Ensure the session detail response (`GET /ai-sessions/{id}`) returns the new fields so the frontend can restore header state on session resume. + +--- + +## Frontend Changes Required + +### 1. AssistantChatPage layout refactor + +Replace current layout (sidebar + chat column + TaskLane side panel) with the stacked cockpit layout described above. + +**New state:** +- `triageMeta: TriageMeta` — `{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }` +- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')` + +**On session load / resume:** populate `triageMeta` from the session response (new fields). + +**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial update, preserve existing non-null values unless AI overwrites). + +### 2. New component: `IncidentHeader` + +``` +frontend/src/components/assistant/IncidentHeader.tsx +``` + +Props: `triageMeta`, `psaTicketId`, `sessionId`, `onFieldSave(field, value)`, `onResolve`, `onOverflow` + +- Renders labelled single-row header +- Each field: micro-label + value + `✏` icon (visible on hover) +- `✏` opens an `EditPopover` (small popover with text input + Save/Cancel) +- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })` +- Empty field shows muted placeholder (e.g. "Unknown client") + +### 3. Refactored component: `StepsPanel` (from TaskLane) + +``` +frontend/src/components/assistant/StepsPanel.tsx +``` + +Same `activeActions` data source. Renders as ordered checklist: +- Completed: `✓` + strikethrough label, muted +- Active: `→` + blue left border, white text, full opacity +- Pending: `○` + muted text + +Script generation CTA: shown at bottom when the active step has `command` containing "script" or when AI has flagged it. + +### 4. New component: `FlowPilotAsks` + +``` +frontend/src/components/assistant/FlowPilotAsks.tsx +``` + +Props: `questions: QuestionItem[]`, `onAnswer(answer: string)` + +- Shows first unanswered question (or empty/hidden state if none) +- When `question.options` is non-null: renders as button row, clicking calls `onAnswer(option)` +- When `question.options` is null: renders compact text input with Send button +- `onAnswer` calls `handleSend` in the parent page with the answer text + +### 5. New component: `WhatWeKnow` + +``` +frontend/src/components/assistant/WhatWeKnow.tsx +``` + +Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)` + +- Renders evidence list with status icons: `✓` (confirmed, green), `✗` (ruled out, red), `?` (pending, muted) +- "+ Add finding" link at bottom opens an inline input row +- Items are editable inline (click to edit) +- State lives in `AssistantChatPage` as part of `triageMeta.evidence_items`, synced to backend via `PATCH /triage` + +### 6. Drag handle — resizable split + +Implement as a thin handle bar between work zone and conversation log. On drag: +- Update `workZoneHeight` in state +- Persist to `localStorage` + +On mount: restore from `localStorage`, default to `55%` of available height. + +### 7. Compact conversation log + +Replace current `` bubble rendering in the log zone with a compact list: + +``` +you: Can't resolve external DNS, internal fine +fp: Ping passed — layer 3 OK. Run nslookup google.com. +you: Timed out on 1.1.1.1 too. +``` + +`ChatMessage` component still used for rich rendering (suggested flows, forks) but in a more compact variant. Full bubble rendering available on hover/expand if needed. + +### 8. Redesigned `ConcludeSessionModal` + +Replaces current simple textarea with the structured handoff form. On open: +1. Call `aiSessionsApi.getHandoffDraft(sessionId)` — streaming — populate fields as stream arrives +2. Render outcome selector + 4 structured fields (all `