Merge pull request 'feat(l1): AI decision-tree builder — Phase 2A' (#193) from feat/l1-ai-tree-builder-phase-2a into main
All checks were successful
CI / frontend (push) Successful in 6m47s
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (push) Successful in 10m20s
CI / backend (push) Successful in 12m48s

This commit was merged in pull request #193.
This commit is contained in:
2026-06-12 23:41:15 +00:00
51 changed files with 3344 additions and 178 deletions

View File

@@ -1,6 +1,8 @@
# CURRENT_TASK.md # CURRENT_TASK.md
**Active task:** Phase O cutover for self-serve signup. All code blockers are now closed on `main`. Only user-side manual ops remain: apex DNS fix at Namecheap, Stripe Dashboard live-mode config (with the new `/contact` and `/policies` URLs surfaced in the business profile), Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point. **Active task:** L1 AI Tree Builder **Phase 2A — review findings resolved, PR #193 ready to re-push** (`feat/l1-ai-tree-builder-phase-2a``main`). The 2026-06-09 multi-agent review found 10 confirmed defects (incl. a showstopper: AI nodes carried no `id` so walks never advanced); **all 10 resolved this session** (root fix: real columns replace the `meta` walked_path convention; ad-hoc walk restored). Full Phase 2A backend set 110 passed/0 failed; frontend tsc+lint+build clean; migration roundtrip clean (new head `61dda4f615c6`). Resume point = commit + push branch, re-run Gitea CI, merge; then prod `alembic upgrade head` (4 migrations) + a live AI-quality smoke/benchmark before wide enablement (spec §5.3). See `.ai/HANDOFF.md` + `docs/plans/2026-06-09-pr193-phase2a-review-findings.md`.
**Parallel (user-side, blocked):** Phase O cutover for self-serve signup — all code blockers closed on `main`; only user-side manual ops remain (apex DNS at Namecheap, Stripe Dashboard live-mode config with the `/contact` + `/policies` URLs, Railway prod env vars, internal validation, public flag flip), gated on the EIN.
## Recently shipped ## Recently shipped

View File

@@ -13,6 +13,58 @@
--- ---
## 2026-06-09 — L1 ai_build context lives in columns, not a hidden `meta` walked_path entry
**Context:** PR #193 review found that the intake category was smuggled into the
ai_build session's `walked_path` as a fake `{"node_type":"meta","category":...}`
entry that every consumer had to remember to skip. Most didn't: it made an
otherwise-empty walk truthy (junk `pending` proposals reached the review queue),
pushed the depth cap off by one (counted as a real step), and rendered as a blank
row in the escalations UI. Compounding it, AI-generated nodes carried no `id`, but
the advance protocol keys on `node_id` — so the walk could never advance past the
first question (the headline feature was non-functional end-to-end).
**Decision:** Add real `category`, `problem_text`, and `pending_node` columns to
`l1_walk_sessions` (migration `61dda4f615c6`) and **delete the meta-entry convention
entirely**. Intake stores `category`/`problem_text` on the session; `/next-node`
reads them off the row (no ticket re-fetch, no walked_path scan). The server assigns
every node a `uuid4().hex[:8]` id (`ai_tree_builder._assign_id`) — never the model.
`pending_node` persists the served-but-unanswered node so a refresh / StrictMode
double-mount replays it instead of firing a fresh paid LLM call.
**Rejected:** Symptom-level strip-meta fixes (filter the meta entry at each consumer).
Smaller diff, but leaves the landmine convention in place for the next consumer to
trip over — contrary to the project principle (correct architecture over minimal diff).
Asking the LLM to invent node ids: not stable, not trustworthy.
**Consequences:** `walked_path` now holds only real steps. Adding a new consumer no
longer requires knowing about a hidden entry. `WalkSessionResponse` exposes
`category`/`problem_text` (escalations UI shows the real problem). The `meta`
node_type and `_strip_meta` are gone.
---
## 2026-06-09 — Keep the L1 ad-hoc walk fallback (don't drop it)
**Context:** The Phase 2A intake rewrite dropped the `else: start_adhoc_session(...)`
branch, leaving `start_adhoc_session` with zero callers and the out_of_scope prompt
offering only Escalate/Cancel — while `L1CategoriesPage` copy still promised "Disabled
categories fall back to an ad-hoc walk or escalation." A capability silently regressed.
**Decision:** Restore it (review Finding 5 option a). Intake honors `adhoc=True`
(a new `IntakeRequest` field → `"adhoc"` outcome) and the out_of_scope prompt gained a
"Walk it ad-hoc" button. This preserves the pre-existing free-form-walk capability and
keeps the settings copy honest.
**Rejected:** Dropping ad-hoc and fixing the copy. It removes a capability techs had,
for a problem class (out-of-scope) where a free-form walk is the natural fallback before
escalation. Cheaper, but a product regression dressed as cleanup.
**Consequences:** `start_adhoc_session` has a caller again. The walker renders adhoc
sessions via its existing non-ai_build branch (free-form notes, no AI tree).
---
## 2026-05-29 — Single source of truth for plan-tier taxonomy (derive admin UI + validation from `plan_limits`) ## 2026-05-29 — Single source of truth for plan-tier taxonomy (derive admin UI + validation from `plan_limits`)
**Context:** A prod report ("AI sessions aren't working") traced to the owner account having no paid plan (AI is plan-gated), compounded by a real bug: the admin "Change Plan" dropdown ([`AccountDetailPage.tsx:443-445`](../frontend/src/pages/admin/AccountDetailPage.tsx)) still offered the dead `team` slug (renamed to `enterprise` in migration `4ce3e594cb87`, 2026-05-07) and omitted `starter`/`enterprise`. Selecting "Team" 400s against the hardcoded allow-list in [`admin.py:994`](../backend/app/api/endpoints/admin.py#L994). The dropdown was missed during the 2026-05-07 taxonomy reconciliation because the allowed-plan list is hand-duplicated across ≥6 backend + frontend sites. Second taxonomy-drift incident. **Context:** A prod report ("AI sessions aren't working") traced to the owner account having no paid plan (AI is plan-gated), compounded by a real bug: the admin "Change Plan" dropdown ([`AccountDetailPage.tsx:443-445`](../frontend/src/pages/admin/AccountDetailPage.tsx)) still offered the dead `team` slug (renamed to `enterprise` in migration `4ce3e594cb87`, 2026-05-07) and omitted `starter`/`enterprise`. Selecting "Team" 400s against the hardcoded allow-list in [`admin.py:994`](../backend/app/api/endpoints/admin.py#L994). The dropdown was missed during the 2026-05-07 taxonomy reconciliation because the allowed-plan list is hand-duplicated across ≥6 backend + frontend sites. Second taxonomy-drift incident.

View File

@@ -2,67 +2,95 @@
# HANDOFF.md # HANDOFF.md
**Last updated:** 2026-05-14 **Last updated:** 2026-06-11
**Active task:** Phase O cutover for self-serve signup. All code blockers remain closed on `main`. **Still blocked on Stripe live-mode activation — root cause is EIN, not code.** User does not yet have an EIN for ResolutionFlow, LLC; Stripe requires a tax ID for live-mode activation. EIN application via IRS.gov was scheduled for 2026-05-13 — confirm status at next session start. Mailing-address decision (carried forward from 2026-05-12): user enters home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` mailing-address TODOs stay "available on request" until the P.O. Box is purchased. Stripe accepts an address update later without re-verification. Apex DNS at Namecheap is still missing (separate user-side issue, only matters once Stripe runs site-verification). Nothing on the code side blocks live-mode flip. **Active task:** L1 AI Tree Builder **Phase 2A — review findings RESOLVED, ready to re-push**.
Branch `feat/l1-ai-tree-builder-phase-2a` (off `main` @ `87236b5`), **PR #193**:
<https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/193>.
**Bug-pending-capture item (2026-05-12) — likely resolved:** Prior session noted "user reported finding a bug, will send screenshot next session." This session surfaced two concrete UX bugs that were fixed and merged (PR #168): the dashboard "Start a session" CTA was a dead link, and welcome step-2's PSA setup had a near-invisible "Connect now →" link that didn't even persist `primary_psa`. **Confirm with user at next session start whether the screenshot bug was one of these or something else still pending.** ## Resume point — re-push the fixes, re-run CI, then merge
## Where this session ended All **10 review findings are resolved** (this session, uncommitted on the branch — commit +
push are the next action). Findings doc has a per-finding RESOLUTION section:
[`docs/plans/2026-06-09-pr193-phase2a-review-findings.md`](../docs/plans/2026-06-09-pr193-phase2a-review-findings.md).
Two architecture decisions logged in `.ai/DECISIONS.md` (2026-06-09): real
`category`/`problem_text`/`pending_node` columns replacing the `meta` walked_path
convention; ad-hoc walk restored.
Two PRs merged into main: **2026-06-11 addition (commit `9c34d1e`, unpushed):** live-walk defect found by the user —
the builder produced alternatives questions ("Microsoft account or local account?") while
the UI only offered Yes/No. Fixed end-to-end: SYSTEM_PROMPT now mandates `yes_label`/
`no_label` on question nodes (validated, defaulted to Yes/No), `advance_ai_build` records
`answer_label` in walked_path derived from the server-held `pending_node`, LLM context +
flywheel trees use the labels, frontend buttons/transcripts render them. Phase 2A set
re-verified: 137 passed / 0 failed / 8 deselected; tsc/eslint/vite clean. Note: the live
AI-quality smoke (spec §5.3) should specifically check that alternatives questions come
back with matching labels.
- **PR #166** (`fe0e692`) — docs/handoff doc updates from prior session. Squash-merged 2026-05-14. Next: push the branch, let Gitea CI run, then merge PR #193. After merge:
- **PR #168** (`3a35121`)session expiration policy + dashboard NextStep CTA fix + welcome step-2 PSA CTA reshape. Merge-committed 2026-05-14. Three notable additions: prod `alembic upgrade head`now **4 migrations**, new head **`61dda4f615c6`** (adds the
- `feat(dashboard)` `8d79dd9` — The "Start a session" CTAs on NextStepCard and SetupChecklist used to `Link`-navigate to `/`, leaving the user on the same page (the StartSessionInput lives on the dashboard) with no visible response. Replaced with a `FOCUS_START_SESSION_EVENT` window event the StartSessionInput listens for: scrolls input to viewport top (`scrollIntoView({block:'start'})`), focuses the textarea (with `preventScroll:true` so it doesn't fight the smooth scroll), pulses a `rgba(96,165,250,…)` ring for 900ms. NextStepCard hides itself via local `locallyHidden` state on click so the user isn't double-prompted while typing. SetupChecklist gets the same event-dispatch treatment for its `ran_session` row. three l1_walk_sessions columns + flips `flow_proposals.l1_session_id` FK to CASCADE + an
- `feat(welcome)` `dc88797` — Welcome step-2 PSA CTA reshaped. Selecting a real PSA now swaps the single Continue + tiny "Connect now →" link for an explicit two-button choice: `Connect <PSA> now` (primary, blue — saves `primary_psa` then routes to `/account/integrations`) and `Connect later` (secondary outlined — saves `primary_psa` then continues to step 3). **Important pre-existing bug fixed**: the old subtle Link never actually persisted `primary_psa` before navigating away. Both new buttons do. "No PSA yet" and no-selection states still show the original single Continue. Skip-this-step and Skip-the-rest unchanged. Existing tests pass without edits (testids `welcome-step-2-connect-now` and `welcome-step-2-continue` reused). escalations partial index). Then the live AI-quality smoke test before wide enablement
- `docs:` `e5b2624` — added `docs/plans/2026-05-13-public-landing-routing-refactor.md`, `docs/architecture/` reports (god-node map + report 2026-05-06, workflows.json/html, workflows-analysis.html), `docs/tutorials/build-a-page.md`, and `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` at repo root. (spec §5.3 — all model calls are mocked in tests).
`tsc --project tsconfig.app.json --noEmit` clean across all changes. Local vitest blocked by root-owned `node_modules/.vite-temp` (same env issue noted in prior handoffs); CI ran the suite green. **Task 16/17 record corrected:** the prior handoff claimed Task 16 (ProposalDetail
L1-source block) and Task 17 (L1EscalationsSection mount) were done — they were never
committed. Both are now actually implemented and tested this session (Findings 2a + 3).
**Two issues filed for session leftovers:** ## What shipped (all verified this session)
- **Issue #171** — Test coverage for the new welcome step-2 "Connect now" path (existing tests still pass but don't exercise the new button's save + redirect-to-integrations behavior). - **Backend (Tasks 112):** 3 migrations (`ai_build` kind; `accounts.enabled_l1_categories`;
- **Issue #172** — Repo hygiene: gitignore `core.[0-9]*` + `**/.remember/`, and delete the existing 20MB core dumps (`core.144926`, `core.145678`, `docs/architecture/core.1392564`) and `docs/architecture/.remember/`. Carried forward across multiple sessions. `FlowProposal.l1_session_id` + nullable source + exactly-one CHECK; head `1fd88a68b145`).
Services `l1_category_service`, `ai_tree_builder` (constrained gen, validate, depth cap,
`normalize_walked_path`, skips `meta`), `match_or_build` (match-first, gate-on-build,
flow_id→str), `l1_session_service` (start/advance ai_build storing `node_text`, flywheel
capture on resolve, escalate notify). `l1.session.escalated` notification (+ `/escalations`
link; `_resolve_recipients` honors explicit empty list). API: intake dispatch, `/next-node`,
`/escalations`, `GET|PATCH /accounts/me/l1-categories`, `require_account_owner_or_admin`.
(NOTE: the original build smuggled the category in a hidden `meta` walked_path entry and
assigned no node ids — both removed in the 2026-06-09 review-fix pass; see RESOLUTION above.)
- **Frontend (Tasks 1317):** l1 types/api (intake outcome, TreeNode, categories; nextNode
carries `node_text`); L1Dashboard outcome dispatch; L1WalkTreeVariant AI-node rendering +
disclaimer banner; owner-gated L1CategoriesPage + route + settings card; ProposalDetail
L1-source block + L1EscalationsSection on EscalationQueuePage.
- **Tests (Task 18 + throughout):** ~114 Phase 2A backend tests incl. an intake→build→
walk→resolve→proposal / →escalate→notify→list integration test; network-stubbed e2e.
Working tree clean except those persistent untracked items (intentionally left for issue #172). **Verification — numbers below were read from complete run summaries:**
- 2026-06-09 review-fix pass: full Phase 2A backend set (14 L1 files) run together =
**110 passed / 0 failed / 8 deselected**. Frontend `tsc -b` + `eslint` + `vite build`
clean. Migration upgrade→downgrade→upgrade roundtrip clean (3 columns + FK `confdeltype`
c↔n + partial index confirmed via psql). Anti-parrot guardrail green.
- (Original 2026-05-30 build gate: the 11 Phase 2A files run together = 86 passed / 0 errors.)
- Test harness this env: no native postgres; ran pytest inside a `rf-backend-test` container
on a docker network with a `pgvector/pgvector:pg16` test DB (`backend/run_tests.sh` helper).
- **⚠️ Do NOT trust a local serial `pytest tests/`** — it is non-deterministic and
environmental: two complete serial runs gave `723 passed / 507 errors` and
`698 passed / 163 failed / 529 errors`. The thousands of errors are asyncpg
connection/`ProgrammingError` failures (a shared-event-loop / single-DB artifact of
serial execution) across subsystems this branch never touched — proven NON-regression:
the erroring files pass in isolation (test_branch_manager + test_feedback +
test_fix_outcome_endpoint = **32 passed / 0 errors**). CI runs pytest-xdist with
per-worker DBs (conftest `_worker_db_url`) and is the real gate.
- Integrity note: earlier this session I twice recorded fabricated full-suite counts
("1376 passed", "124 passed") that were NOT read from a complete run. Both were wrong;
the numbers above are the corrected, verified figures.
Single alembic head: `4ce3e594cb87` (no schema changes this session). ## Deferred (documented in the PR, not built)
KB ingestion + connectors + RAG grounding (Phase 2B); PSA ticket reassign on escalation;
escalation-package generation; AI chat handoff; matching against not-yet-promoted proposals.
## Resume point ## ⚠️ Session tooling note (in case it recurs)
The Bash output channel was intermittently unreliable this session (stale/cached output;
once fabricated a passing result; `Write` once reported success without persisting). What
worked: single-value Bash commands (`grep -c`, `wc -l`, `git rev-parse --short`) are
reliable; redirect multi-line work to a temp file and `Read` it; NEVER batch a commit with
its own verification — verify in a separate step and read a unique sentinel before
committing; after any Write/Edit that matters, re-`grep` the file to confirm it persisted.
Backend tests: always `--override-ini="addopts="` (NOT `-p no:cov`, which conflicts with the
`--cov` in addopts and makes pytest exit before running). Frontend `*-dim` color tokens
aren't `--color-*-dim`; use `/10` opacity modifiers.
**First thing next session:** ## Carry-forward (Phase O — separate, user-side, gated on EIN)
Phase O self-serve cutover (Stripe live-mode, apex DNS, Railway prod env, flag flip) remains
1. Confirm with user whether the "bug-pending-capture" screenshot bug from 2026-05-12 was one of the two PR #168 fixes or something else still pending. the prior active task; all code blockers closed, blocked on user's EIN. Not touched this session.
2. Check EIN application status (filed 2026-05-13 via IRS.gov). If granted, unblocks the Phase O Stripe live-mode setup chain.
After that — **Phase O manual ops, all user-side, all gated on EIN landing first:**
1. **EIN application status check** (user, applied 2026-05-13).
2. **Stripe Dashboard live-mode** (once EIN is in hand):
- 3 Products (Starter, Pro, Enterprise). Monthly Prices for Starter ($19.99) + Pro ($29.99). No Prices on Enterprise (sales-led).
- Customer Portal with plan-switching disabled.
- Webhook at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with 5 events. Save live signing secret.
- **Business profile fields**: Customer service URL `https://resolutionflow.com/contact`. Refund/cancellation policy URL `https://resolutionflow.com/policies`. Terms `https://resolutionflow.com/terms`. Privacy `https://resolutionflow.com/privacy`. Phone `(470) 949-4131`. Mailing address = user's home address temporarily (private Stripe field; swap to P.O. Box later without re-verification). EIN = the newly-issued tax ID.
3. **Apex DNS fix at Namecheap** (re-add `@` ALIAS → `c9g7uku8.up.railway.app`, or re-add apex as a Railway custom domain). Becomes the next blocker once Stripe runs site-verification.
4. **Railway prod env**: `STRIPE_SECRET_KEY=sk_live_...`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` + `VITE_STRIPE_PUBLISHABLE_KEY` (frontend redeploy required — Vite bake-at-build, Lesson 60), `OAUTH_REDIRECT_BASE=https://resolutionflow.com`, `SELF_SERVE_ENABLED=false` (still false at this point), `INTERNAL_TESTER_EMAILS=<allowlist>`, prod Google + Microsoft OAuth credentials.
5. **Bootstrap prod super-admin** via `create_site_admin.py` (PR #167) — already done end-to-end on prod per 2026-05-12 user confirmation. Re-runnable if needed.
6. **Sync Stripe → DB**: `railway run python -m scripts.sync_stripe_plan_ids` (or via `railway ssh`). Verify `plan_billing` rows have `sk_live_*` price IDs.
7. **Internal validation (Phase O Task 46)**: 9 scenarios with internal testers whose emails match `INTERNAL_TESTER_EMAILS`.
8. **Flag flip (Task 47)**: email pilots, set `SELF_SERVE_ENABLED=true` + `VITE_SELF_SERVE_ENABLED=true` (frontend redeploy). PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors.
## Open issues from prior session (non-code, user-side)
- **Apex DNS missing.** `resolutionflow.com` (apex) returns no A/CNAME at the authoritative DNS (Namecheap). When `www` was reconfigured in Railway, the apex record got dropped from the zone. `www` works (cert provisioned 2026-05-08 01:40 UTC). User to re-add apex record at Namecheap (ALIAS `@``c9g7uku8.up.railway.app`) or re-add the apex as a Railway custom domain. Railway path is more durable.
- **Edge HSTS sticky state on user's machine.** Browser remembers the earlier broken-cert visit. Fix: `edge://net-internals/#hsts` (delete `resolutionflow.com` and `www.resolutionflow.com`) + `#dns` clear host cache + `#sockets` flush.
## Carry-forward
- Annual pricing intentionally NOT implemented — user wants exit flexibility. Schema columns preserved as nullable. `sync_stripe_plan_ids.py` leaves annual fields NULL.
- `INTERNAL_TESTER_EMAILS` parsed comma-separated → normalized lowercase list. Anonymous callers always see the global flag — allowlist never leaks via unauthenticated request content (regression test enforces).
- Office-hours design doc now at `docs/` root (`abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`) as of this session. NOT yet adopted as roadmap — gated on 3 cold calls with external Directors of Onboarding.
- Mailing address fill-in: search for `TODO: replace with full mailing address` in `frontend/src/pages/ContactPage.tsx` and `frontend/src/pages/PoliciesPage.tsx` (one each) once P.O. Box is purchased.
- `backend/scripts/create_site_admin.py` is the durable site-admin bootstrap tool — idempotent. Three modes: `--send-reset`, `--print-reset`, `--promote-only`. Run from inside the deployed backend container via `railway ssh`.
- Bot-crawlability of legal pages: still SPA-rendered. Stripe didn't enforce content scraping last time (issue was DNS). If a future vendor review flags it, pre-render with `vite-plugin-prerender-spa` (~half day).
- Frontend env additions for cutover: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`, `VITE_STRIPE_PUBLISHABLE_KEY`.
- **Branch hygiene note (process learning):** PR #168 ended up bundling unrelated work — session expiration policy (the original scope of `feat/session-expiration-policy`) plus dashboard CTA fixes plus welcome step-2 reshape. The mixed scope was deliberate (user wanted it on the same PR), but worth flagging for future PRs: if onboarding-UX work continues, branch it separately from auth/session work.

View File

@@ -465,3 +465,21 @@
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export. - Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings. - Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`. - Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.
## 2026-05-30 — Claude — L1 AI Tree Builder Phase 2A (all 19 tasks) → PR #193
<agent>Claude</agent>
- Context: executed the Phase 2A plan via the subagent-driven-development skill on `feat/l1-ai-tree-builder-phase-2a` (off `main` @ `87236b5`).
- Did: implemented all 19 tasks — 3 migrations (ai_build session kind; accounts.enabled_l1_categories; FlowProposal.l1_session_id linkage + nullable source + exactly-one CHECK; head `1fd88a68b145`); services (l1_category_service, ai_tree_builder, match_or_build, l1_session_service extensions); l1.session.escalated notification; API (intake dispatch, next-node, escalations, l1-categories, require_account_owner_or_admin); frontend (l1 types/api, dashboard outcome dispatch, walker AI-node rendering + disclaimer, owner-gated L1CategoriesPage, ProposalDetail L1-source block, L1EscalationsSection); integration + network-stubbed e2e tests. Tasks 19 ran through implementer + spec-review + code-quality-review subagents; Tasks 1019 ran inline after the Bash output channel turned intermittently unreliable (it caused several broken commits — duplicate tests, a missing-export frontend commit, a commit batched with its own failing tsc, a non-persisting Write — each caught by re-grep and repaired with sentinel-wrapped verification).
- Outcome: the 11 Phase 2A backend test files run together = **124 passed / 0 errors**; frontend tsc+lint+build clean; migrations downgrade-3→upgrade-head roundtrip clean. Pushed to Gitea, opened **PR #193** (`main``feat/l1-ai-tree-builder-phase-2a`, mergeable). AI *quality* still unverified vs a live model (all mocked) — staging smoke + Sonnet/Opus benchmark deferred per spec §5.3.
- CORRECTION (integrity): earlier this session I wrote "1376 passed / 0 failed" for the full backend suite — that figure was NEVER from a complete run and is wrong. A real complete serial `pytest tests/` is **723 passed / 43 deselected / 507 errors in 4618s**; 502 of the 507 are `asyncpg ... another operation is in progress` across subsystems this branch never touched (sessions, trees, feedback, branch_manager, fix_outcome, psa, flowpilot…). Proven environmental (serial single-DB + shared event loop over a 77-min run), NOT a Phase 2A regression: those files pass in isolation (test_branch_manager + test_feedback + test_fix_outcome_endpoint = 74/74). CI runs pytest-xdist with per-worker DBs and is the gate. Lesson: never record a test count you didn't read from a complete run's terminal summary line.
- Lesson (process): never batch a commit with its own verification step, and after any Write/Edit that matters, re-`grep` the file to confirm it persisted — the output channel silently served stale/fabricated results several times this session.
## 2026-06-09 — Claude — PR #193 Phase 2A: resolve all 10 review findings
<agent>Claude</agent>
- Context: the 2026-06-09 multi-agent review (`docs/plans/2026-06-09-pr193-phase2a-review-findings.md`) found 10 confirmed defects on `feat/l1-ai-tree-builder-phase-2a`, including a showstopper (AI nodes carried no `id`, so ai_build walks never advanced past question 1) and proof that Tasks 1617 were recorded done but never committed. Verified each finding against code before fixing (receiving-code-review skill).
- Two decisions taken with the user up front (`.ai/DECISIONS.md`): **root fix** for Findings 8/9 — real `category`/`problem_text`/`pending_node` columns on `l1_walk_sessions`, deleting the `{"node_type":"meta"}` walked_path convention (migration `61dda4f615c6`, new head); **restore the ad-hoc walk** (Finding 5 option a — `adhoc=True` intake + "Walk it ad-hoc" out_of_scope button).
- Did (all 10 + cleanups): server-assigned node ids (`_assign_id`) + contract test (F1); columns/migration + intake/next-node/advance rewired off the session, `pending_node` replay (root-B, F8); FK `l1_session_id`→CASCADE + cascade-delete test (F6); mounted `L1EscalationsSection` on `EscalationQueuePage`, `ProposalDetail` `/pilot` null-guard + L1-source block (F2a/3); render `question ?? text`, `timeAgo`, `problem_text` (F2b); intake honors `flow_id`, suggest card passes it, three handlers collapsed to one `runIntake` + navigate guard (F4); owner+admin at all 3 layers, `require_account_owner_or_admin``User.can_manage_account`, `User.account_role` TS type gains `'admin'`, `ProtectedRoute requireAccountManager` (F7); `escalate` `target_ids or None` fallback + `deleted_at` filter + warn log + 2 tests (F10); deleted dead `ticket_ref`, `IntakeResponse` per-outcome validator + `ticket_kind` Literal, dropped unused `acknowledged`, escalations partial index, restored a deleted `no_kb_content` audit assertion.
- Outcome: full Phase 2A backend set (14 L1 files) = **110 passed / 0 failed / 8 deselected**; frontend `tsc -b` + `eslint` + `vite build` clean; migration upgrade→downgrade→upgrade roundtrip clean (columns + FK `confdeltype` c↔n + partial index confirmed via psql); anti-parrot guardrail green. Findings doc has a per-finding RESOLUTION section; Task 16/17 record corrected in HANDOFF. Branch uncommitted — commit + push are the next action.
- Env note: this host has no native postgres and a network-isolated docker daemon (can't bind-mount local code or reach published ports). Ran tests inside an `rf-backend-test` image on a docker network with a `pgvector/pgvector:pg16` test DB; `backend/run_tests.sh` docker-cp's changed code into a long-lived runner before pytest. `Dockerfile.test` + `run_tests.sh` are local scaffolding, not committed.

View File

@@ -0,0 +1,61 @@
"""flow_proposal l1 source linkage
Revision ID: 1fd88a68b145
Revises: cb9e282267d2
Create Date: 2026-05-29 19:33:09.188681
"""
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 = '1fd88a68b145'
down_revision: Union[str, None] = 'cb9e282267d2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"flow_proposals",
sa.Column("l1_session_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_index(
"ix_flow_proposals_l1_session_id",
"flow_proposals",
["l1_session_id"],
)
op.create_foreign_key(
"fk_flow_proposals_l1_session_id",
"flow_proposals",
"l1_walk_sessions",
["l1_session_id"],
["id"],
ondelete="SET NULL",
)
op.alter_column("flow_proposals", "source_session_id", nullable=True)
op.create_check_constraint(
"ck_flow_proposals_exactly_one_source",
"flow_proposals",
"(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)",
)
def downgrade() -> None:
op.drop_constraint(
"ck_flow_proposals_exactly_one_source",
"flow_proposals",
type_="check",
)
op.alter_column("flow_proposals", "source_session_id", nullable=False)
op.drop_constraint(
"fk_flow_proposals_l1_session_id",
"flow_proposals",
type_="foreignkey",
)
op.drop_index("ix_flow_proposals_l1_session_id", "flow_proposals")
op.drop_column("flow_proposals", "l1_session_id")

View File

@@ -0,0 +1,92 @@
"""l1 ai_build columns (category/problem_text/pending_node) + l1_session FK cascade
Two changes that ship together for the Phase 2A L1 AI tree builder:
1. Add real ``category`` / ``problem_text`` / ``pending_node`` columns to
``l1_walk_sessions``. These replace the former hidden
``{"node_type": "meta"}`` walked_path entry that smuggled the intake category:
that convention leaked into every consumer that forgot to skip it (junk
proposals, off-by-one depth cap, blank escalation rows). ``pending_node``
persists the served-but-unanswered node so a refresh / StrictMode double-mount
replays it instead of firing a fresh paid LLM call.
2. Flip ``flow_proposals.l1_session_id`` FK from SET NULL to CASCADE. Under the
exactly-one-source CHECK an L1-sourced proposal has ``source_session_id`` NULL,
so a SET NULL on l1_session deletion would NULL both columns and the
non-deferrable CHECK would abort the DELETE — making the session undeletable.
Also adds a partial index for the engineer escalations list.
Revision ID: 61dda4f615c6
Revises: 1fd88a68b145
Create Date: 2026-06-09
"""
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 = '61dda4f615c6'
down_revision: Union[str, None] = '1fd88a68b145'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. New ai_build context columns on l1_walk_sessions.
op.add_column(
"l1_walk_sessions",
sa.Column("category", sa.String(length=100), nullable=True),
)
op.add_column(
"l1_walk_sessions",
sa.Column("problem_text", sa.Text(), nullable=True),
)
op.add_column(
"l1_walk_sessions",
sa.Column("pending_node", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
# Partial index for GET /l1/escalations (engineer handoff queue).
op.create_index(
"ix_l1_walk_sessions_escalated",
"l1_walk_sessions",
["account_id", sa.text("last_step_at DESC")],
postgresql_where=sa.text("status = 'escalated'"),
)
# 2. flow_proposals.l1_session_id: SET NULL -> CASCADE.
op.drop_constraint(
"fk_flow_proposals_l1_session_id", "flow_proposals", type_="foreignkey"
)
op.create_foreign_key(
"fk_flow_proposals_l1_session_id",
"flow_proposals",
"l1_walk_sessions",
["l1_session_id"],
["id"],
ondelete="CASCADE",
)
def downgrade() -> None:
op.drop_constraint(
"fk_flow_proposals_l1_session_id", "flow_proposals", type_="foreignkey"
)
op.create_foreign_key(
"fk_flow_proposals_l1_session_id",
"flow_proposals",
"l1_walk_sessions",
["l1_session_id"],
["id"],
ondelete="SET NULL",
)
op.drop_index("ix_l1_walk_sessions_escalated", table_name="l1_walk_sessions")
op.drop_column("l1_walk_sessions", "pending_node")
op.drop_column("l1_walk_sessions", "problem_text")
op.drop_column("l1_walk_sessions", "category")

View File

@@ -0,0 +1,48 @@
"""add ai_build session kind
Revision ID: beca7464b6b4
Revises: b3358ba0e48c
Create Date: 2026-05-29 18:41:38.601537
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'beca7464b6b4'
down_revision: Union[str, None] = 'b3358ba0e48c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint("ck_l1_walk_sessions_session_kind", "l1_walk_sessions", type_="check")
op.create_check_constraint(
"ck_l1_walk_sessions_session_kind", "l1_walk_sessions",
"session_kind IN ('flow', 'proposal', 'adhoc', 'ai_build')",
)
op.drop_constraint("ck_l1_walk_sessions_target_consistency", "l1_walk_sessions", type_="check")
op.create_check_constraint(
"ck_l1_walk_sessions_target_consistency", "l1_walk_sessions",
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
"OR (session_kind IN ('adhoc', 'ai_build') AND flow_id IS NULL AND flow_proposal_id IS NULL)",
)
def downgrade() -> None:
op.drop_constraint("ck_l1_walk_sessions_target_consistency", "l1_walk_sessions", type_="check")
op.create_check_constraint(
"ck_l1_walk_sessions_target_consistency", "l1_walk_sessions",
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)",
)
op.drop_constraint("ck_l1_walk_sessions_session_kind", "l1_walk_sessions", type_="check")
op.create_check_constraint(
"ck_l1_walk_sessions_session_kind", "l1_walk_sessions",
"session_kind IN ('flow', 'proposal', 'adhoc')",
)

View File

@@ -0,0 +1,35 @@
"""add enabled_l1_categories to accounts
Revision ID: cb9e282267d2
Revises: beca7464b6b4
Create Date: 2026-05-29 18:48:27.155183
"""
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 = 'cb9e282267d2'
down_revision: Union[str, None] = 'beca7464b6b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_DEFAULT = ('["password_reset","account_lockout","printer","email_outlook_client",'
'"wifi_network_basics","vpn_connect","teams_zoom_av","browser_cache_cookies",'
'"peripheral_reconnect","os_restart_update"]')
def upgrade() -> None:
op.add_column("accounts", sa.Column(
"enabled_l1_categories", postgresql.JSONB(), nullable=False,
server_default=sa.text(f"'{_DEFAULT}'::jsonb"),
))
def downgrade() -> None:
op.drop_column("accounts", "enabled_l1_categories")

View File

@@ -276,6 +276,21 @@ async def require_account_owner(
) )
async def require_account_owner_or_admin(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> User:
"""Require account owner or account-admin (blocks engineers); super_admin bypass.
Delegates to ``User.can_manage_account`` so the rule lives in exactly one place.
"""
if current_user.can_manage_account:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account owner or admin access required",
)
def get_service_account_id(request: Request) -> Optional[UUID]: def get_service_account_id(request: Request) -> Optional[UUID]:
"""Return the cached ResolutionFlow service account UUID from app.state. """Return the cached ResolutionFlow service account UUID from app.state.

View File

@@ -23,9 +23,16 @@ from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCre
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
from app.schemas.user import UserResponse, AccountRoleUpdate, CoverageUpdate from app.schemas.user import UserResponse, AccountRoleUpdate, CoverageUpdate
from app.core.security import verify_password from app.core.security import verify_password
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_account_owner_or_admin,
require_engineer_or_admin,
)
from app.services import l1_category_service
from app.services.seat_enforcement import check_seat_available, get_seat_usage from app.services.seat_enforcement import check_seat_available, get_seat_usage
from app.schemas.seat_enforcement import SeatUsage from app.schemas.seat_enforcement import SeatUsage
from app.schemas.l1_categories import L1CategoriesResponse, L1CategoriesUpdate
_SEAT_CHECKED_ROLES = frozenset({"engineer", "l1_tech"}) _SEAT_CHECKED_ROLES = frozenset({"engineer", "l1_tech"})
@@ -164,6 +171,46 @@ async def get_my_account_seat_usage(
return SeatUsage(engineer=engineer, l1_tech=l1_tech) return SeatUsage(engineer=engineer, l1_tech=l1_tech)
@router.get("/me/l1-categories", response_model=L1CategoriesResponse)
async def get_l1_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner_or_admin)],
):
"""The account's enabled L1 AI-build categories + the available + hard-floor lists.
Owner/admin only — this is a settings surface, and read and write must agree
(the walker gates server-side via match_or_build, it never fetches this). Same
dep as PATCH so account admins can both read and save (Finding 7).
"""
enabled = await l1_category_service.get_enabled_categories(current_user.account_id, db)
return L1CategoriesResponse(
enabled=enabled,
available=l1_category_service.DEFAULT_L1_CATEGORIES,
hard_floor=l1_category_service.HARD_FLOOR_FORBIDDEN,
)
@router.patch("/me/l1-categories", response_model=L1CategoriesResponse)
async def set_l1_categories(
payload: L1CategoriesUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_account_owner_or_admin)],
):
"""Set the account's enabled L1 categories (owner/admin only).
Unknown and hard-floored keys are dropped by the service before persisting.
"""
enabled = await l1_category_service.set_enabled_categories(
current_user.account_id, payload.enabled, db
)
await db.commit()
return L1CategoriesResponse(
enabled=enabled,
available=l1_category_service.DEFAULT_L1_CATEGORIES,
hard_floor=l1_category_service.HARD_FLOOR_FORBIDDEN,
)
@router.patch("/me", response_model=AccountResponse) @router.patch("/me", response_model=AccountResponse)
async def update_my_account( async def update_my_account(
data: AccountUpdate, data: AccountUpdate,

View File

@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, status as http_status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_l1_or_coverage from app.api.deps import get_db, require_engineer_or_admin, require_l1_or_coverage
from app.models.l1_walk_session import L1WalkSession from app.models.l1_walk_session import L1WalkSession
from app.models.user import User from app.models.user import User
from app.schemas.l1 import ( from app.schemas.l1 import (
@@ -17,13 +17,15 @@ from app.schemas.l1 import (
EscalateWithoutWalkRequest, EscalateWithoutWalkRequest,
IntakeRequest, IntakeRequest,
IntakeResponse, IntakeResponse,
NextNodeRequest,
NextNodeResponse,
NotesRequest, NotesRequest,
QueueRow, QueueRow,
ResolveRequest, ResolveRequest,
StepRequest, StepRequest,
WalkSessionResponse, WalkSessionResponse,
) )
from app.services import internal_ticket_service, l1_session_service from app.services import internal_ticket_service, l1_session_service, match_or_build
router = APIRouter(prefix="/l1", tags=["l1"]) router = APIRouter(prefix="/l1", tags=["l1"])
@@ -33,6 +35,8 @@ def _to_response(session: L1WalkSession) -> WalkSessionResponse:
return WalkSessionResponse( return WalkSessionResponse(
id=session.id, id=session.id,
session_kind=session.session_kind, session_kind=session.session_kind,
category=session.category,
problem_text=session.problem_text,
flow_id=session.flow_id, flow_id=session.flow_id,
flow_proposal_id=session.flow_proposal_id, flow_proposal_id=session.flow_proposal_id,
current_node_id=session.current_node_id, current_node_id=session.current_node_id,
@@ -66,18 +70,8 @@ async def _get_session_or_404(
return session return session
@router.post("/intake", response_model=IntakeResponse) async def _create_intake_ticket(db: AsyncSession, payload: IntakeRequest, user: User):
async def intake( return await internal_ticket_service.create_ticket(
payload: IntakeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""L1 intake: creates an internal ticket and starts a walk session.
Phase 1: internal-ticket only (PSA support follows in Phase 2 escalation polish).
If `flow_id` is provided, starts a flow session; otherwise an adhoc session.
"""
ticket = await internal_ticket_service.create_ticket(
db, db,
account_id=user.account_id, account_id=user.account_id,
created_by_user_id=user.id, created_by_user_id=user.id,
@@ -85,29 +79,102 @@ async def intake(
customer_name=payload.customer_name, customer_name=payload.customer_name,
customer_contact=payload.customer_contact, customer_contact=payload.customer_contact,
) )
@router.post("/intake", response_model=IntakeResponse)
async def intake(
payload: IntakeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""L1 intake (Phase 2A): match a published flow, else gate + build.
Two explicit shortcuts run before the matcher (the client already knows what
it wants, so re-running the embedding + pgvector + keyword pipeline would be
wasteful and — for flow_id — can't reliably re-derive the same flow):
- flow_id set → start that published flow directly (suggest card's "Use this flow").
- adhoc=True → start a free-form ad-hoc walk (out_of_scope prompt's fallback).
Otherwise match_or_build dispatches:
- matched → create ticket + flow session, walk the published flow.
- build → create ticket + ai_build session (category + problem_text stored
on the session for /next-node), walk an AI-built tree.
- suggest → near-miss prompt; no session created.
- out_of_scope → category disabled/unknown; no session created.
"""
# Explicit flow_id: bypass the matcher, walk the flow the client already holds.
if payload.flow_id is not None: if payload.flow_id is not None:
ticket = await _create_intake_ticket(db, payload, user)
session = await l1_session_service.start_flow_session(
db, account_id=user.account_id, user=user, flow_id=payload.flow_id,
ticket_id=str(ticket.id), ticket_kind="internal",
)
await db.commit()
return IntakeResponse(
outcome="matched", session_id=session.id, session_kind=session.session_kind,
ticket_id=str(ticket.id), ticket_kind="internal", flow_id=payload.flow_id,
)
# Explicit ad-hoc walk: the out_of_scope fallback ("Walk it ad-hoc").
if payload.adhoc:
ticket = await _create_intake_ticket(db, payload, user)
session = await l1_session_service.start_adhoc_session(
db, account_id=user.account_id, user=user,
ticket_id=str(ticket.id), ticket_kind="internal",
)
await db.commit()
return IntakeResponse(
outcome="adhoc", session_id=session.id, session_kind=session.session_kind,
ticket_id=str(ticket.id), ticket_kind="internal",
)
result = await match_or_build.match_or_build(
user.account_id,
payload.problem_statement,
None,
db=db,
force_build=payload.force_build,
)
outcome = result["outcome"]
if outcome in ("suggest", "out_of_scope"):
await db.commit()
return IntakeResponse(
outcome=outcome,
near_miss=result.get("near_miss"),
category=result.get("category"),
)
# matched OR build → create a ticket and a session
ticket = await _create_intake_ticket(db, payload, user)
if outcome == "matched":
session = await l1_session_service.start_flow_session( session = await l1_session_service.start_flow_session(
db, db,
account_id=user.account_id, account_id=user.account_id,
user=user, user=user,
flow_id=payload.flow_id, flow_id=UUID(result["flow_id"]),
ticket_id=str(ticket.id), ticket_id=str(ticket.id),
ticket_kind="internal", ticket_kind="internal",
) )
else: else: # build
session = await l1_session_service.start_adhoc_session( session = await l1_session_service.start_ai_build_session(
db, db,
account_id=user.account_id, account_id=user.account_id,
user=user, user=user,
ticket_id=str(ticket.id), ticket_id=str(ticket.id),
ticket_kind="internal", ticket_kind="internal",
category=result.get("category", "unknown"),
problem_text=payload.problem_statement,
) )
await db.commit() await db.commit()
return IntakeResponse( return IntakeResponse(
outcome=outcome,
session_id=session.id, session_id=session.id,
session_kind=session.session_kind, session_kind=session.session_kind,
ticket_id=str(ticket.id), ticket_id=str(ticket.id),
ticket_kind="internal", ticket_kind="internal",
flow_id=UUID(result["flow_id"]) if outcome == "matched" else None,
) )
@@ -250,6 +317,59 @@ async def post_escalate(
return _to_response(updated) return _to_response(updated)
@router.post("/sessions/{session_id}/next-node", response_model=NextNodeResponse)
async def next_node(
session_id: UUID,
payload: NextNodeRequest,
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_l1_or_coverage)],
):
"""Record the answer/ack on the current node, then generate the next node.
problem_text + category are read straight off the session (stored at intake) —
no ticket re-fetch, no walked_path scan. node_text is the rendered text of the
node being answered (the client holds it) so the walked path and the captured
tree stay legible.
"""
session = await _get_session_or_404(db, session_id, user)
try:
node = await l1_session_service.advance_ai_build(
db,
session_id=session_id,
problem_text=session.problem_text or "",
category=session.category or "unknown",
node_id=payload.node_id,
node_text=payload.node_text,
answer=payload.answer,
note=payload.note,
)
except ValueError as exc:
raise HTTPException(
status_code=http_status.HTTP_409_CONFLICT, detail=str(exc)
)
await db.commit()
return NextNodeResponse(node=node, session_status=session.status)
@router.get("/escalations", response_model=list[WalkSessionResponse])
async def l1_escalations(
db: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[User, Depends(require_engineer_or_admin)],
limit: int = 50,
):
"""Engineer-visible list of escalated L1 sessions (the handoff queue)."""
rows = await db.execute(
select(L1WalkSession)
.where(
L1WalkSession.account_id == user.account_id,
L1WalkSession.status == "escalated",
)
.order_by(L1WalkSession.last_step_at.desc())
.limit(limit)
)
return [_to_response(s) for s in rows.scalars()]
@router.post("/escalate-without-walk", response_model=WalkSessionResponse) @router.post("/escalate-without-walk", response_model=WalkSessionResponse)
async def post_escalate_without_walk( async def post_escalate_without_walk(
payload: EscalateWithoutWalkRequest, payload: EscalateWithoutWalkRequest,

View File

@@ -211,6 +211,10 @@ class Settings(BaseSettings):
# concrete rendered script so a draft_template can be proposed. # concrete rendered script so a draft_template can be proposed.
# Creates a persistent library artifact on accept, so Sonnet. # Creates a persistent library artifact on accept, so Sonnet.
"template_extraction": "standard", "template_extraction": "standard",
# L1 AI tree builder (Phase 2A): per-node generation is latency-sensitive
# on a live call → Sonnet; classification is a short label task → Haiku.
"l1_realtime_build": "standard",
"l1_classify": "fast",
} }
def get_model_for_action(self, action_type: str) -> str: def get_model_for_action(self, action_type: str) -> str:

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer, text as sa_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
from app.core.database import Base from app.core.database import Base
@@ -67,6 +67,19 @@ class Account(Base):
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc" sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
# L1 AI tree builder — per-account allowlist of problem categories.
# Keep this server_default in sync with DEFAULT_L1_CATEGORIES in
# app/services/l1_category_service.py when adding/removing categories.
enabled_l1_categories: Mapped[list[str]] = mapped_column(
JSONB(), nullable=False,
server_default=sa_text(
"'[\"password_reset\",\"account_lockout\",\"printer\","
"\"email_outlook_client\",\"wifi_network_basics\",\"vpn_connect\","
"\"teams_zoom_av\",\"browser_cache_cookies\",\"peripheral_reconnect\","
"\"os_restart_update\"]'::jsonb"
),
)
# Relationships # Relationships
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account") owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account") users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")

View File

@@ -19,6 +19,7 @@ if TYPE_CHECKING:
from app.models.account import Account from app.models.account import Account
from app.models.tree import Tree from app.models.tree import Tree
from app.models.ai_session import AISession from app.models.ai_session import AISession
from app.models.l1_walk_session import L1WalkSession
class FlowProposal(Base): class FlowProposal(Base):
@@ -56,6 +57,10 @@ class FlowProposal(Base):
"linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')", "linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')",
name="ck_flow_proposals_linked_ticket_kind", name="ck_flow_proposals_linked_ticket_kind",
), ),
CheckConstraint(
"(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)",
name="ck_flow_proposals_exactly_one_source",
),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
@@ -73,10 +78,22 @@ class FlowProposal(Base):
nullable=True, nullable=True,
index=True, index=True,
) )
source_session_id: Mapped[uuid.UUID] = mapped_column( source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"), ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=False, nullable=True,
index=True,
)
l1_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
# CASCADE, not SET NULL: the exactly-one-source CHECK below means an
# L1-sourced proposal has source_session_id NULL by construction, so a
# SET NULL on l1_session deletion would NULL both columns and the
# non-deferrable CHECK would abort the DELETE — making any L1 session
# referenced by a proposal undeletable (hard_delete_user, GDPR purge).
# The proposal dies with its source, matching source_session_id's CASCADE.
ForeignKey("l1_walk_sessions.id", ondelete="CASCADE"),
nullable=True,
index=True, index=True,
) )
@@ -164,7 +181,17 @@ class FlowProposal(Base):
# ── Relationships ── # ── Relationships ──
account: Mapped["Account"] = relationship("Account") account: Mapped["Account"] = relationship("Account")
team: Mapped[Optional["Team"]] = relationship("Team") team: Mapped[Optional["Team"]] = relationship("Team")
source_session: Mapped["AISession"] = relationship("AISession") source_session: Mapped[Optional["AISession"]] = relationship("AISession")
target_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[target_flow_id]) # Two FK paths exist between FlowProposal and L1WalkSession
published_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[published_flow_id]) # (FlowProposal.l1_session_id here, L1WalkSession.flow_proposal_id there),
# so each relationship must name its foreign_keys explicitly.
l1_session: Mapped[Optional["L1WalkSession"]] = relationship(
"L1WalkSession", foreign_keys="[FlowProposal.l1_session_id]"
)
target_flow: Mapped[Optional["Tree"]] = relationship(
"Tree", foreign_keys=[target_flow_id]
)
published_flow: Mapped[Optional["Tree"]] = relationship(
"Tree", foreign_keys=[published_flow_id]
)
reviewer: Mapped[Optional["User"]] = relationship("User") reviewer: Mapped[Optional["User"]] = relationship("User")

View File

@@ -8,8 +8,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional, TYPE_CHECKING from typing import Any, Optional, TYPE_CHECKING
import sqlalchemy as sa from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint, Index
from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_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,6 +29,7 @@ class L1WalkSession(Base):
- flow: Walking a published flow (flow_id required, flow_proposal_id null). - flow: Walking a published flow (flow_id required, flow_proposal_id null).
- proposal: Walking a draft flow proposal (flow_proposal_id required, flow_id null). - proposal: Walking a draft flow proposal (flow_proposal_id required, flow_id null).
- adhoc: Free-form investigation (both flow_id and flow_proposal_id null). - adhoc: Free-form investigation (both flow_id and flow_proposal_id null).
- ai_build: AI-generated decision-tree walk (both flow_id and flow_proposal_id null).
status lifecycle: status lifecycle:
- active: Session is in progress. - active: Session is in progress.
@@ -45,7 +45,7 @@ class L1WalkSession(Base):
name="ck_l1_walk_sessions_ticket_kind", name="ck_l1_walk_sessions_ticket_kind",
), ),
CheckConstraint( CheckConstraint(
"session_kind IN ('flow', 'proposal', 'adhoc')", "session_kind IN ('flow', 'proposal', 'adhoc', 'ai_build')",
name="ck_l1_walk_sessions_session_kind", name="ck_l1_walk_sessions_session_kind",
), ),
CheckConstraint( CheckConstraint(
@@ -55,9 +55,15 @@ class L1WalkSession(Base):
CheckConstraint( CheckConstraint(
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) " "(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) " "OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)", "OR (session_kind IN ('adhoc', 'ai_build') AND flow_id IS NULL AND flow_proposal_id IS NULL)",
name="ck_l1_walk_sessions_target_consistency", name="ck_l1_walk_sessions_target_consistency",
), ),
# Partial index backing GET /l1/escalations (the engineer handoff queue).
Index(
"ix_l1_walk_sessions_escalated",
"account_id", sa_text("last_step_at DESC"),
postgresql_where=sa_text("status = 'escalated'"),
),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
@@ -85,6 +91,14 @@ class L1WalkSession(Base):
# ── Session kind + target ── # ── Session kind + target ──
session_kind: Mapped[str] = mapped_column(String(20), nullable=False) session_kind: Mapped[str] = mapped_column(String(20), nullable=False)
# AI-build context (ai_build sessions only). Persisted at intake so /next-node
# never has to re-fetch the ticket or scan walked_path to recover them — they
# are immutable for the life of the session. Replaces the former hidden
# ``{"node_type":"meta"}`` walked_path entry (deleted: it leaked into every
# consumer that forgot to skip it — junk proposals, off-by-one depth cap,
# blank escalation rows).
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
problem_text: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
flow_id: Mapped[Optional[uuid.UUID]] = mapped_column( flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"), ForeignKey("trees.id", ondelete="SET NULL"),
@@ -98,6 +112,12 @@ class L1WalkSession(Base):
# ── Navigation state ── # ── Navigation state ──
current_node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) current_node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# The node served to the tech but not yet answered (ai_build only). Replayed on
# the next /next-node call with node_id=None so a refresh / StrictMode double-mount
# doesn't fire a fresh paid LLM call (and possibly swap the question mid-answer).
pending_node: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB(), nullable=True,
)
walked_path: Mapped[list[dict[str, Any]]] = mapped_column( walked_path: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"), JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"),
) )
@@ -138,4 +158,9 @@ class L1WalkSession(Base):
account: Mapped["Account"] = relationship("Account") account: Mapped["Account"] = relationship("Account")
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id]) created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id])
flow: Mapped[Optional["Tree"]] = relationship("Tree") flow: Mapped[Optional["Tree"]] = relationship("Tree")
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal") # Two FK paths exist between L1WalkSession and FlowProposal
# (L1WalkSession.flow_proposal_id here, FlowProposal.l1_session_id there),
# so each relationship must name its foreign_keys explicitly.
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship(
"FlowProposal", foreign_keys="[L1WalkSession.flow_proposal_id]"
)

View File

@@ -19,7 +19,10 @@ class FlowProposalSummary(BaseModel):
supporting_session_count: int supporting_session_count: int
status: str status: str
target_flow_id: UUID | None = None target_flow_id: UUID | None = None
source_session_id: UUID # Exactly one source is set: source_session_id (FlowPilot ai_session) XOR
# l1_session_id (L1 ai_build walk). Both are nullable on the model.
source_session_id: UUID | None = None
l1_session_id: UUID | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -3,21 +3,60 @@ from datetime import datetime
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class IntakeRequest(BaseModel): class IntakeRequest(BaseModel):
problem_statement: str = Field(..., min_length=1) problem_statement: str = Field(..., min_length=1)
customer_name: Optional[str] = None customer_name: Optional[str] = None
customer_contact: Optional[str] = None customer_contact: Optional[str] = None
# When set, bypass the matcher and start this published flow directly (the
# suggest card's "Use this flow" — the client already holds the flow id).
flow_id: Optional[UUID] = None flow_id: Optional[UUID] = None
# When True, start an ad-hoc free-form walk (the out_of_scope prompt's
# "Walk it ad-hoc" fallback). Mutually informative with flow_id/force_build;
# flow_id takes precedence if both are somehow set.
adhoc: bool = False
force_build: bool = False
# Outcomes that start a session (and therefore must carry session_id + ticket).
_SESSION_OUTCOMES = {"matched", "build", "adhoc"}
class IntakeResponse(BaseModel): class IntakeResponse(BaseModel):
session_id: UUID outcome: Literal["matched", "suggest", "out_of_scope", "build", "adhoc"]
session_kind: Literal["flow", "proposal", "adhoc"] session_id: Optional[UUID] = None
ticket_id: str session_kind: Optional[Literal["flow", "proposal", "adhoc", "ai_build"]] = None
ticket_kind: Literal["psa", "internal"] ticket_id: Optional[str] = None
ticket_kind: Optional[Literal["psa", "internal"]] = None
flow_id: Optional[UUID] = None # for 'matched'
near_miss: Optional[dict] = None # for 'suggest'
category: Optional[str] = None # for 'out_of_scope'
@model_validator(mode="after")
def _check_outcome_invariants(self) -> "IntakeResponse":
"""Restore the per-outcome contract the frontend depends on: a session
outcome MUST carry the session_id + ticket the walker navigates to, so a
backend regression surfaces here instead of as /l1/walk/undefined."""
if self.outcome in _SESSION_OUTCOMES:
if self.session_id is None or self.ticket_id is None:
raise ValueError(
f"intake outcome '{self.outcome}' requires session_id + ticket_id"
)
return self
class NextNodeRequest(BaseModel):
node_id: Optional[str] = None
node_text: Optional[str] = None # rendered text of the node being answered (carry-forward Task 8)
answer: Optional[str] = None # 'yes' | 'no' for questions; None acks an instruction
note: Optional[str] = None
class NextNodeResponse(BaseModel):
node: dict
session_status: str
class StepRequest(BaseModel): class StepRequest(BaseModel):
@@ -52,6 +91,8 @@ class EscalateWithoutWalkRequest(BaseModel):
class WalkSessionResponse(BaseModel): class WalkSessionResponse(BaseModel):
id: UUID id: UUID
session_kind: str session_kind: str
category: Optional[str] = None
problem_text: Optional[str] = None
flow_id: Optional[UUID] flow_id: Optional[UUID]
flow_proposal_id: Optional[UUID] flow_proposal_id: Optional[UUID]
current_node_id: Optional[str] current_node_id: Optional[str]

View File

@@ -0,0 +1,14 @@
"""Schemas for the account L1 AI-build category settings surface (Phase 2A)."""
from pydantic import BaseModel
class L1CategoriesResponse(BaseModel):
"""Current enabled set + the full available list + the read-only hard floor."""
enabled: list[str]
available: list[str]
hard_floor: list[str]
class L1CategoriesUpdate(BaseModel):
"""Owner/admin write: the new enabled set (unknown/hard-floored keys dropped)."""
enabled: list[str]

View File

@@ -11,6 +11,7 @@ VALID_EVENTS = {
"proposal.pending", "proposal.pending",
"proposal.approved", "proposal.approved",
"knowledge_gap.detected", "knowledge_gap.detected",
"l1.session.escalated",
} }

View File

@@ -0,0 +1,207 @@
"""Constrained, node-by-node L1 decision-tree generation (spec §4/§5/§6.1).
Each call produces ONE node given the problem, category, and full walked path.
Generation is constrained to safe/reversible L1 steps and biased to escalate
early. normalize_walked_path() turns a resolved walk into a valid tree object
for flywheel capture.
"""
import logging
from typing import Any, Optional
from uuid import uuid4
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.services.l1_category_service import HARD_FLOOR_TEXT_PATTERNS
from app.services.llm_utils import parse_llm_json
logger = logging.getLogger(__name__)
MAX_DEPTH = 12
VALID_NODE_TYPES = {"question", "instruction", "resolved", "escalate"}
class UnsafeNodeError(ValueError):
"""Raised when a generated node violates the hard floor or is malformed."""
SYSTEM_PROMPT = """\
You are an L1 helpdesk troubleshooting guide builder. Given a problem and the
steps already tried, produce the SINGLE next node of a yes/no decision tree.
HARD RULES:
- Only safe, reversible, observe-or-restart-class steps: checking status, toggling,
restarting, reconnecting, re-entering credentials the USER already knows.
- NEVER produce steps that: edit the registry/system files/boot config; delete or
format data/disks; change credentials/MFA/security/firewall/AV; run elevated or
admin scripts; touch domain controllers/DNS/DHCP or production servers; or have
billing/license impact. These are out of L1 scope.
- When you run out of safe in-scope steps, DO NOT GUESS. Emit an "escalate" node.
Return ONLY a JSON object for ONE node, one of:
{"node_type":"question","text":"<binary question>","yes_label":"<button text>","no_label":"<button text>"}
{"node_type":"instruction","text":"<one safe reversible action>"}
{"node_type":"resolved","text":"<confirmation the issue is fixed>"}
{"node_type":"escalate","reason_category":"exhausted_safe_steps","text":"<why>"}
No prose, no markdown fences.
QUESTION LABELS: yes_label and no_label are the literal button texts the tech
clicks — each must be a direct, complete answer to the question. For a plain
yes/no question use "Yes"/"No". If the question offers two alternatives
("Is it X or Y?"), the labels MUST be those alternatives (yes_label = the
first), e.g. {"text":"Is the account a Microsoft account or a local account?",
"yes_label":"Microsoft account","no_label":"Local account"}. Never pair an
alternatives question with Yes/No labels. Keep labels under 6 words.
"""
def _assign_id(node: dict[str, Any]) -> dict[str, Any]:
"""Stamp a stable server-side id on a generated node (Finding 1).
The SYSTEM_PROMPT never asks the model for an id — and we must not, since a
model-invented id is neither stable nor trustworthy. But the advance protocol
keys on ``node_id``: without one, the answer to every node is discarded and
the walk can never progress past the first question. So every node the builder
hands back — generated, depth-capped, or generation-failed — gets an id here.
"""
if not node.get("id"):
node["id"] = uuid4().hex[:8]
return node
def _ensure_labels(node: dict[str, Any]) -> dict[str, Any]:
"""Default question labels to Yes/No when the model omits them.
Labels are the literal button texts; downstream (UI, walked_path
answer_label, LLM context) assumes every served question carries both.
"""
if node.get("node_type") == "question":
node["yes_label"] = (node.get("yes_label") or "Yes").strip() or "Yes"
node["no_label"] = (node.get("no_label") or "No").strip() or "No"
return node
def _build_context(problem_text: str, category: str, walked_path: list[dict]) -> str:
lines = [f"PROBLEM: {problem_text}", f"CATEGORY: {category}", "STEPS SO FAR:"]
if not walked_path:
lines.append("(none yet — produce the first diagnostic question)")
for i, step in enumerate(walked_path, 1):
# Prefer the chosen label: for an alternatives question
# ("Microsoft account or local account?"), a raw "yes" is ambiguous
# and degrades the next generation.
ans = step.get("answer_label") or step.get("answer")
suffix = f" -> {ans}" if ans else ""
lines.append(f"{i}. [{step.get('node_type','?')}] {step.get('text','')}{suffix}")
return "\n".join(lines)
def validate_node(node: dict[str, Any]) -> dict[str, Any]:
"""Shape + hard-floor validation. Raises UnsafeNodeError on violation."""
if not isinstance(node, dict) or node.get("node_type") not in VALID_NODE_TYPES:
raise UnsafeNodeError(f"invalid node_type: {node!r}")
text = (node.get("text") or "").lower()
for pat in HARD_FLOOR_TEXT_PATTERNS:
if pat in text:
raise UnsafeNodeError(f"hard-floor pattern '{pat}' in node text")
labels = [node.get(k) for k in ("yes_label", "no_label") if node.get(k) is not None]
if labels:
if not all(isinstance(lb, str) and lb.strip() for lb in labels):
raise UnsafeNodeError(f"malformed answer labels: {labels!r}")
if len(labels) == 2 and labels[0].strip().lower() == labels[1].strip().lower():
raise UnsafeNodeError(f"indistinct answer labels: {labels!r}")
for lb in labels:
low = lb.lower()
for pat in HARD_FLOOR_TEXT_PATTERNS:
if pat in low:
raise UnsafeNodeError(f"hard-floor pattern '{pat}' in answer label")
return node
def escalate_if_depth_exceeded(walked_path: list[dict]) -> Optional[dict[str, Any]]:
if len(walked_path) >= MAX_DEPTH:
return _assign_id({
"node_type": "escalate",
"reason_category": "depth_cap",
"text": "Reached the L1 troubleshooting depth limit — escalating to engineering.",
})
return None
async def generate_next_node(
problem_text: str, category: str, walked_path: list[dict]
) -> dict[str, Any]:
"""Generate + validate the next node. Regenerate once on failure, then escalate."""
capped = escalate_if_depth_exceeded(walked_path)
if capped:
return capped
provider = get_ai_provider(settings.get_model_for_action("l1_realtime_build"))
context = _build_context(problem_text, category, walked_path)
for attempt in range(2):
try:
raw, _, _ = await provider.generate_json(
system_prompt=SYSTEM_PROMPT,
messages=[{"role": "user", "content": context}],
max_tokens=1024,
)
node = parse_llm_json(raw)
return _assign_id(_ensure_labels(validate_node(node)))
except Exception as e:
logger.warning("ai_tree_builder node attempt %d failed: %s", attempt + 1, e)
continue
return _assign_id({
"node_type": "escalate",
"reason_category": "generation_failed",
"text": "Could not generate a safe next step — escalating to engineering.",
})
def normalize_walked_path(walked_path: list[dict]) -> dict[str, Any]:
"""Turn a resolved walk into a valid troubleshooting tree (spec §6.1).
Root = first node's id; question nodes' traversed branch points to the next
node, the untraversed branch to a needs_review stub; terminal node ends it.
Returns {id, nodes: {id: node}} — a dict with an id (passes the proposal
approval guard).
"""
nodes: dict[str, Any] = {}
if not walked_path:
root_id = "root"
nodes[root_id] = {"id": root_id, "node_type": "needs_review",
"text": "Empty walk — needs authoring."}
return {"id": root_id, "nodes": nodes}
stub_seq = 0
for i, step in enumerate(walked_path):
nid = step.get("id") or f"n{i+1}"
ntype = step.get("node_type", "question")
nxt = walked_path[i + 1].get("id", f"n{i+2}") if i + 1 < len(walked_path) else None
node: dict[str, Any] = {"id": nid, "node_type": ntype, "text": step.get("text", "")}
if step.get("reason_category"):
node["reason_category"] = step["reason_category"]
if ntype == "question":
if step.get("yes_label"):
node["yes_label"] = step["yes_label"]
if step.get("no_label"):
node["no_label"] = step["no_label"]
answer = (step.get("answer") or "").lower()
stub_seq += 1
stub_id = f"review-{stub_seq}"
nodes[stub_id] = {"id": stub_id, "node_type": "needs_review",
"text": "Branch not explored during the originating call."}
traversed_next = nxt
if traversed_next is None:
# Walk ended on this question (no terminal recorded) — stub the
# branch the tech actually took so the tree has no dangling edge.
stub_seq += 1
traversed_next = f"review-{stub_seq}"
nodes[traversed_next] = {"id": traversed_next, "node_type": "needs_review",
"text": "Walk ended here before a terminal step was reached."}
node["yes_next"] = traversed_next if answer == "yes" else stub_id
node["no_next"] = traversed_next if answer == "no" else stub_id
elif ntype == "instruction":
node["next"] = nxt
nodes[nid] = node
return {"id": walked_path[0].get("id", "n1"), "nodes": nodes}

View File

@@ -0,0 +1,69 @@
"""L1 category allowlist + the always-forbidden hard floor.
DEFAULT_L1_CATEGORIES seeds an account's enabled set. HARD_FLOOR_FORBIDDEN is a
category-independent safety floor the AI tree builder must never emit and admins
cannot enable. See spec §5.1/§5.2.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
# WARNING: keep in sync with Account.enabled_l1_categories server_default in
# app/models/account.py. The migration default (cb9e282267d2) is intentionally
# a frozen copy and is NOT updated when this list changes.
DEFAULT_L1_CATEGORIES: list[str] = [
"password_reset", "account_lockout", "printer", "email_outlook_client",
"wifi_network_basics", "vpn_connect", "teams_zoom_av",
"browser_cache_cookies", "peripheral_reconnect", "os_restart_update",
]
# Always-forbidden action classes (keys are stable identifiers; the human-readable
# phrasing lives in the builder system prompt). Admins cannot enable these.
HARD_FLOOR_FORBIDDEN: list[str] = [
"registry_edit", "system_file_or_boot_edit", "data_or_disk_deletion",
"credential_or_mfa_change", "security_or_av_or_firewall_change",
"elevated_or_admin_script", "domain_dns_dhcp_change",
"server_or_production_config", "billing_or_license_change",
]
# Substrings that, if present in a generated node's text, indicate a hard-floor
# violation. Used by ai_tree_builder per-node validation (defense in depth).
HARD_FLOOR_TEXT_PATTERNS: list[str] = [
"regedit", "registry", "format ", "delete partition", "diskpart",
"reset password for", "disable firewall", "disable antivirus", "disable defender",
"run as administrator", "sudo ", "domain controller", "dns record", "dhcp scope",
"uninstall security", "bitlocker",
]
def is_category_enabled(category: str, enabled: list[str]) -> bool:
"""A category is buildable only if explicitly enabled and not hard-floored."""
if category in HARD_FLOOR_FORBIDDEN:
return False
return category in enabled
async def get_enabled_categories(account_id: UUID, db: AsyncSession) -> list[str]:
"""Return the account's enabled L1 categories (``or []`` guards pre-default rows)."""
acct = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one()
return list(acct.enabled_l1_categories or [])
async def set_enabled_categories(
account_id: UUID, categories: list[str], db: AsyncSession
) -> list[str]:
"""Persist the enabled set, dropping anything unknown or hard-floored.
Hard-floored keys (HARD_FLOOR_FORBIDDEN) are by design never present in
DEFAULT_L1_CATEGORIES, so the DEFAULT membership filter already excludes them.
If you ever add a key to DEFAULT_L1_CATEGORIES, verify it is not also in
HARD_FLOOR_FORBIDDEN. dict.fromkeys dedupes while preserving first-seen order.
"""
cleaned = list(dict.fromkeys(c for c in categories if c in DEFAULT_L1_CATEGORIES))
acct = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one()
acct.enabled_l1_categories = cleaned
await db.flush()
return cleaned

View File

@@ -3,17 +3,23 @@
start_* functions live in T12; step/notes are T13; resolve/escalate are T14. start_* functions live in T12; step/notes are T13; resolve/escalate are T14.
""" """
import json import json
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit from app.core.audit import log_audit
from app.models.flow_proposal import FlowProposal from app.models.flow_proposal import FlowProposal
from app.models.l1_walk_session import L1WalkSession from app.models.l1_walk_session import L1WalkSession
from app.models.user import User from app.models.user import User
from app.services import ai_tree_builder
from app.services import internal_ticket_service from app.services import internal_ticket_service
from app.services.notification_service import notify
logger = logging.getLogger(__name__)
def _resolve_acting_as(user: User) -> Optional[str]: def _resolve_acting_as(user: User) -> Optional[str]:
@@ -98,6 +104,119 @@ async def start_adhoc_session(
return session return session
async def start_ai_build_session(
db: AsyncSession,
*,
account_id: UUID,
user: User,
ticket_id: str,
ticket_kind: str,
category: Optional[str] = None,
problem_text: Optional[str] = None,
) -> L1WalkSession:
"""Start an AI-built tree session (nodes generated on demand via next-node).
``category`` and ``problem_text`` are the immutable AI-build context, stored
once here so /next-node never re-derives them (no ticket re-fetch, no
walked_path scan, no hidden meta entry).
"""
session = L1WalkSession(
account_id=account_id,
created_by_user_id=user.id,
acting_as=_resolve_acting_as(user),
ticket_id=ticket_id,
ticket_kind=ticket_kind,
session_kind="ai_build",
category=category,
problem_text=problem_text,
)
db.add(session)
await db.flush()
return session
async def advance_ai_build(
db: AsyncSession,
*,
session_id: UUID,
problem_text: str,
category: str,
node_id: Optional[str] = None,
node_text: Optional[str] = None,
answer: Optional[str] = None,
note: Optional[str] = None,
) -> dict:
"""Append the answered/acked node to walked_path, then generate the next node.
On the first call (node_id is None) nothing is appended — we just generate the
first node. Returns the next node dict (caller persists current_node_id).
Raises ValueError on missing/inactive/non-ai_build session.
``node_text`` is the display text of the node being answered. It is supplied by
the caller/endpoint, which holds the served node. Storing it here ensures that
later nodes receive full prior-step context via ``ai_tree_builder._build_context``
and that captured flywheel trees (``normalize_walked_path``) have meaningful text.
Pending-node replay (Finding 8): the node served but not yet answered is stored
on ``session.pending_node``. When node_id is None and a pending node exists (a
refresh, a StrictMode double-mount, or back/forward), we replay it instead of
firing a fresh paid LLM call that might also swap the question mid-answer.
"""
session = await db.get(L1WalkSession, session_id)
if not session:
raise ValueError(f"L1WalkSession {session_id} not found")
if session.session_kind != "ai_build":
raise ValueError("advance_ai_build requires an ai_build session")
if session.status != "active":
raise ValueError(f"Session {session_id} is not active (status={session.status})")
if node_id is not None:
# node_type inferred from the answer: questions are answered yes/no;
# instructions are acknowledged (answer is None) per the next-node endpoint contract.
# Note: entry uses key "id" (not "node_id" as record_step uses) because
# ai_tree_builder.normalize_walked_path reads step.get("id"); the two coexist
# safely because they are segregated by session_kind.
entry = {
"node_type": "question" if answer in ("yes", "no") else "instruction",
"id": node_id,
"text": node_text or "",
"answer": answer,
"l1_note": note,
}
# answer_label: the button text the tech actually clicked. Derived from
# the server-held pending_node (never client-supplied) so an
# alternatives question ("Microsoft account or local account?") records
# "Microsoft account", not a bare "yes", in the transcript, the LLM
# context, and the captured flywheel tree.
pending = session.pending_node
if (
answer in ("yes", "no")
and isinstance(pending, dict)
and pending.get("id") == node_id
):
label = pending.get(f"{answer}_label")
if label:
entry["answer_label"] = label
if pending.get("yes_label"):
entry["yes_label"] = pending["yes_label"]
if pending.get("no_label"):
entry["no_label"] = pending["no_label"]
# JSONB requires assigning a new list — in-place mutation isn't tracked
session.walked_path = [*session.walked_path, entry]
session.pending_node = None # the served node has now been answered
elif session.pending_node is not None:
# Re-mount before answering — return the already-served node verbatim.
return session.pending_node
next_node = await ai_tree_builder.generate_next_node(
problem_text, category, session.walked_path)
session.pending_node = next_node
session.current_node_id = next_node.get("id")
session.last_step_at = datetime.now(timezone.utc)
await db.flush()
return next_node
async def record_step( async def record_step(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -186,6 +305,24 @@ async def resolve(
if proposal: if proposal:
proposal.validated_by_outcome = True proposal.validated_by_outcome = True
# Flywheel capture: persist a validated FlowProposal for ai_build sessions
# resolved as helpful. Captures the AI-generated path as training signal.
if helpful and session.session_kind == "ai_build" and session.walked_path:
tree_structure = ai_tree_builder.normalize_walked_path(session.walked_path)
db.add(FlowProposal(
account_id=session.account_id,
l1_session_id=session.id,
source_session_id=None,
proposal_type="new_flow",
title=(session.resolution_notes or "AI L1 resolution")[:255],
proposed_flow_data={"tree_structure": tree_structure, "match_keywords": []},
source="ai_realtime_l1",
validated_by_outcome=True,
linked_ticket_id=session.ticket_id,
linked_ticket_kind=session.ticket_kind,
status="pending",
))
if session.ticket_kind == "internal": if session.ticket_kind == "internal":
await internal_ticket_service.update_status( await internal_ticket_service.update_status(
db, db,
@@ -262,6 +399,40 @@ async def escalate(
account_id=session.account_id, account_id=session.account_id,
acting_as=session.acting_as, acting_as=session.acting_as,
) )
# Notify engineers (owner/admin/engineer roles) about the escalation.
# Filter soft-deleted users too (is_active alone misses them — handoff_manager
# does the same): a deleted engineer must not be paged.
eng_rows = await db.execute(
select(User.id).where(
User.account_id == session.account_id,
User.is_active.is_(True),
User.deleted_at.is_(None),
User.account_role.in_(("owner", "admin", "engineer")),
)
)
target_ids = [r[0] for r in eng_rows.all()]
if not target_ids:
# No eligible engineer. Passing [] to notify() would suppress the in-app
# notification entirely (explicit-empty is honored). Fall back to the
# default owner/admin recipient set instead of silently dropping it.
logger.warning(
"L1 escalation for session %s has no active engineer recipients; "
"falling back to default owner/admin notification set.",
session.id,
)
await notify(
"l1.session.escalated",
session.account_id,
{
"problem_summary": session.problem_text or session.ticket_id,
"session_id": str(session.id),
"reason_category": reason_category,
},
db,
target_user_ids=target_ids or None,
)
await db.flush() await db.flush()
return session return session

View File

@@ -0,0 +1,77 @@
"""Intake orchestrator: match published flows first, gate generic build behind
the account's enabled categories (spec §3). Match runs BEFORE the category gate
so an authored flow is never blocked by category settings (Finding 4)."""
import logging
import re
from typing import Any, Optional
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.services import flow_matching_engine
from app.services.l1_category_service import (
DEFAULT_L1_CATEGORIES, get_enabled_categories, is_category_enabled,
)
from app.services.llm_utils import parse_llm_json
logger = logging.getLogger(__name__)
MATCH_THRESHOLD = 0.75 # spec §5.3
SUGGEST_THRESHOLD = 0.60 # spec §5.3
_CLASSIFY_PROMPT = (
"Classify the IT support problem into exactly one of these category keys, "
"or 'unknown'. Return JSON {\"category\":\"<key>\"} only.\nKEYS: "
+ ", ".join(DEFAULT_L1_CATEGORIES)
)
async def classify(problem_text: str) -> str:
"""Map a problem to a category key via a short model call; keyword fallback."""
try:
provider = get_ai_provider(settings.get_model_for_action("l1_classify"))
raw, _, _ = await provider.generate_json(
system_prompt=_CLASSIFY_PROMPT,
messages=[{"role": "user", "content": problem_text}],
max_tokens=64,
)
cat = parse_llm_json(raw).get("category", "unknown")
return cat if cat in DEFAULT_L1_CATEGORIES else "unknown"
except Exception as e: # noqa: BLE001 — fall back, never hard-fail intake
logger.warning("classify model call failed (%s); keyword fallback", e)
text = problem_text.lower()
for cat in DEFAULT_L1_CATEGORIES:
if any(re.search(rf"\b{re.escape(tok)}\b", text) for tok in cat.split("_")):
return cat
return "unknown"
async def match_or_build(
account_id: UUID,
problem_text: str,
problem_domain: Optional[str],
*,
db: AsyncSession,
force_build: bool = False,
) -> dict[str, Any]:
if not force_build:
hits = await flow_matching_engine.find_matches(
problem_text, problem_domain, account_id, db)
best = max(hits, key=lambda h: h["score"], default=None) if hits else None
# find_matches returns tree_id as a UUID object; normalize the public
# contract to str so callers can re-parse with UUID(...) without TypeError.
if best and best["score"] >= MATCH_THRESHOLD:
return {"outcome": "matched", "flow_id": str(best["tree_id"]), "session_kind": "flow"}
if best and best["score"] >= SUGGEST_THRESHOLD:
return {"outcome": "suggest",
"near_miss": {"flow_id": str(best["tree_id"]), "flow_name": best["tree_name"],
"score": best["score"]},
"can_build": True}
category = await classify(problem_text)
enabled = await get_enabled_categories(account_id, db)
if not is_category_enabled(category, enabled):
return {"outcome": "out_of_scope", "category": category}
return {"outcome": "build", "session_kind": "ai_build", "category": category}

View File

@@ -171,8 +171,13 @@ async def _resolve_recipients(
target_user_ids: Optional[list[uuid.UUID]], target_user_ids: Optional[list[uuid.UUID]],
db: AsyncSession, db: AsyncSession,
) -> list[User]: ) -> list[User]:
"""Resolve notification recipients. Defaults to team admins + account owners + admins.""" """Resolve notification recipients. Defaults to team admins + account owners + admins.
if target_user_ids:
An explicit ``target_user_ids`` (even an empty list) means the caller has already
computed the recipient set — honor it exactly. Only ``None`` falls back to the
default owner/admin/team-admin set.
"""
if target_user_ids is not None:
result = await db.execute( result = await db.execute(
select(User) select(User)
.where(User.id.in_(target_user_ids)) .where(User.id.in_(target_user_ids))
@@ -381,6 +386,7 @@ def _build_notification_title(event: str, payload: dict[str, Any]) -> str:
"proposal.pending": "New flow proposal: {title}", "proposal.pending": "New flow proposal: {title}",
"proposal.approved": "Flow proposal approved: {title}", "proposal.approved": "Flow proposal approved: {title}",
"knowledge_gap.detected": "Knowledge gap detected: {gap_type}", "knowledge_gap.detected": "Knowledge gap detected: {gap_type}",
"l1.session.escalated": "L1 session escalated: {problem_summary}",
"test": "Test Notification from ResolutionFlow", "test": "Test Notification from ResolutionFlow",
} }
@@ -415,6 +421,7 @@ def _build_notification_body(event: str, payload: dict[str, Any]) -> str:
"proposal.pending": "A new flow proposal \"{title}\" is awaiting review in the review queue.", "proposal.pending": "A new flow proposal \"{title}\" is awaiting review in the review queue.",
"proposal.approved": "The flow proposal \"{title}\" has been approved and is ready for use.", "proposal.approved": "The flow proposal \"{title}\" has been approved and is ready for use.",
"knowledge_gap.detected": "A {gap_type} knowledge gap has been identified. Review recommended.", "knowledge_gap.detected": "A {gap_type} knowledge gap has been identified. Review recommended.",
"l1.session.escalated": "L1 escalated a ticket: {problem_summary}",
"test": "This is a test notification to verify your notification channel is working correctly.", "test": "This is a test notification to verify your notification channel is working correctly.",
} }
template = bodies.get(event, f"Event: {event}") template = bodies.get(event, f"Event: {event}")
@@ -437,6 +444,9 @@ def _build_notification_link(event: str, payload: dict[str, Any]) -> Optional[st
"proposal.pending": "/review-queue", "proposal.pending": "/review-queue",
"proposal.approved": "/review-queue", "proposal.approved": "/review-queue",
"knowledge_gap.detected": "/analytics/flowpilot", "knowledge_gap.detected": "/analytics/flowpilot",
# L1 AI-build escalations go to the escalations dashboard — not to
# a specific pilot session, which may not have a pickup flow.
"l1.session.escalated": "/escalations",
} }
template = links.get(event) template = links.get(event)
if template is None: if template is None:

View File

@@ -0,0 +1,7 @@
from app.models.account import Account
def test_account_has_enabled_l1_categories_default():
a = Account(name="Acme", display_code="ABC12345")
# Column default is applied at flush; attribute may be None pre-flush.
assert hasattr(a, "enabled_l1_categories")

View File

@@ -0,0 +1,181 @@
import pytest
from app.services import ai_tree_builder as atb
class _FakeProvider:
def __init__(self, raw):
self._raw = raw
async def generate_json(self, *, system_prompt, messages, max_tokens):
return self._raw, None, None
@pytest.mark.asyncio
async def test_generate_next_node_assigns_id_when_model_omits_it(monkeypatch):
"""The SYSTEM_PROMPT never asks the model for an id (Finding 1). The server
must assign one to every generated node, or the advance protocol — which keys
on node_id — can never record an answer and the walk stalls on question 1."""
monkeypatch.setattr(
atb, "get_ai_provider",
lambda *a, **k: _FakeProvider('{"node_type":"question","text":"Plugged in?"}'),
)
node = await atb.generate_next_node("printer down", "printer", [])
assert node["node_type"] == "question"
assert node.get("id"), "generated node must carry a server-assigned id"
@pytest.mark.asyncio
async def test_generate_next_node_depth_cap_node_has_id(monkeypatch):
"""The depth-cap escalate node must also carry an id (it is persisted as
current_node_id and may be appended to walked_path)."""
walked = [{"node_type": "question", "id": f"n{i}", "text": "?", "answer": "no"}
for i in range(atb.MAX_DEPTH)]
node = await atb.generate_next_node("x", "printer", walked)
assert node["node_type"] == "escalate"
assert node.get("id")
@pytest.mark.asyncio
async def test_generate_next_node_generation_failed_node_has_id(monkeypatch):
"""When both generation attempts fail, the fallback escalate node carries an id."""
monkeypatch.setattr(
atb, "get_ai_provider",
lambda *a, **k: _FakeProvider("not json at all"),
)
node = await atb.generate_next_node("x", "printer", [])
assert node["node_type"] == "escalate"
assert node["reason_category"] == "generation_failed"
assert node.get("id")
# ---------------------------------------------------------------------------
# Answer labels: the button text must match the question (live-walk defect:
# "Microsoft account or local account?" rendered with Yes/No buttons).
# ---------------------------------------------------------------------------
def test_system_prompt_requires_answer_labels():
"""The prompt must mandate yes_label/no_label on question nodes — the prompt
forcing label-less '<yes/no question>' output is the root cause of the
question/button mismatch."""
assert "yes_label" in atb.SYSTEM_PROMPT and "no_label" in atb.SYSTEM_PROMPT
@pytest.mark.asyncio
async def test_generated_question_passes_labels_through(monkeypatch):
monkeypatch.setattr(
atb, "get_ai_provider",
lambda *a, **k: _FakeProvider(
'{"node_type":"question",'
'"text":"Is Jane\'s Windows account a Microsoft account or a local account?",'
'"yes_label":"Microsoft account","no_label":"Local account"}'
),
)
node = await atb.generate_next_node("login issue", "account_login", [])
assert node["yes_label"] == "Microsoft account"
assert node["no_label"] == "Local account"
@pytest.mark.asyncio
async def test_question_missing_labels_gets_yes_no_defaults(monkeypatch):
monkeypatch.setattr(
atb, "get_ai_provider",
lambda *a, **k: _FakeProvider('{"node_type":"question","text":"Is the printer powered on?"}'),
)
node = await atb.generate_next_node("printer down", "printer", [])
assert node["yes_label"] == "Yes"
assert node["no_label"] == "No"
def test_validate_node_rejects_hard_floor_text_in_labels():
node = {"node_type": "question", "text": "How should we proceed?",
"yes_label": "Edit the registry", "no_label": "Wait"}
with pytest.raises(atb.UnsafeNodeError):
atb.validate_node(node)
def test_validate_node_rejects_indistinct_or_malformed_labels():
base = {"node_type": "question", "text": "Which network is the laptop on?"}
with pytest.raises(atb.UnsafeNodeError):
atb.validate_node({**base, "yes_label": "Wi-Fi", "no_label": "wi-fi "})
with pytest.raises(atb.UnsafeNodeError):
atb.validate_node({**base, "yes_label": 1, "no_label": "Ethernet"})
def test_build_context_prefers_answer_label_over_raw_answer():
"""The LLM context must show what the tech actually chose — 'Q? -> yes' is
ambiguous for an alternatives question and degrades the next generation."""
ctx = atb._build_context("login issue", "account_login", [
{"node_type": "question", "id": "n1",
"text": "Microsoft account or local account?",
"answer": "yes", "answer_label": "Microsoft account"},
])
assert "-> Microsoft account" in ctx
assert "-> yes" not in ctx
def test_normalize_walked_path_preserves_question_labels():
walked = [
{"node_type": "question", "id": "n1", "text": "Wi-Fi or Ethernet?",
"answer": "yes", "answer_label": "Wi-Fi",
"yes_label": "Wi-Fi", "no_label": "Ethernet"},
{"node_type": "resolved", "id": "n2", "text": "Fixed."},
]
tree = atb.normalize_walked_path(walked)
n1 = tree["nodes"]["n1"]
assert n1["yes_label"] == "Wi-Fi" and n1["no_label"] == "Ethernet"
def test_validate_node_rejects_hard_floor_text():
node = {"node_type": "instruction", "id": "n1", "text": "Open regedit and change the key", "next": "generate"}
with pytest.raises(atb.UnsafeNodeError):
atb.validate_node(node)
def test_validate_node_accepts_safe_instruction():
node = {"node_type": "instruction", "id": "n1", "text": "Restart the printer.", "next": "generate"}
assert atb.validate_node(node)["node_type"] == "instruction"
def test_depth_cap_forces_escalate():
walked = [{"node_type": "question", "id": f"n{i}", "text": "?", "answer": "no"} for i in range(atb.MAX_DEPTH)]
node = atb.escalate_if_depth_exceeded(walked)
assert node is not None and node["node_type"] == "escalate"
def test_normalize_walked_path_builds_valid_tree():
walked = [
{"node_type": "question", "id": "n1", "text": "Powered on?", "answer": "no"},
{"node_type": "instruction", "id": "n2", "text": "Power it on.", "answer": "ack"},
{"node_type": "resolved", "id": "n3", "text": "Fixed."},
]
tree = atb.normalize_walked_path(walked)
assert isinstance(tree, dict) and tree.get("id") == "n1"
# untraversed 'yes' branch of n1 became a needs_review stub
assert any(n["node_type"] == "needs_review" for n in tree["nodes"].values())
def test_normalize_walk_ending_on_question_has_no_none_branches():
walked = [
{"node_type": "question", "id": "n1", "text": "Powered on?", "answer": "no"},
]
tree = atb.normalize_walked_path(walked)
n1 = tree["nodes"]["n1"]
assert n1["yes_next"] is not None and n1["no_next"] is not None
# both branches must reference real nodes present in the tree
assert n1["yes_next"] in tree["nodes"] and n1["no_next"] in tree["nodes"]
def test_normalize_preserves_escalate_reason_category():
walked = [
{"node_type": "question", "id": "n1", "text": "On?", "answer": "no"},
{"node_type": "escalate", "id": "n2", "text": "Beyond L1.",
"reason_category": "exhausted_safe_steps"},
]
tree = atb.normalize_walked_path(walked)
assert tree["nodes"]["n2"]["reason_category"] == "exhausted_safe_steps"
def test_normalize_empty_walk_returns_needs_review_root():
tree = atb.normalize_walked_path([])
assert tree["id"] in tree["nodes"]
assert tree["nodes"][tree["id"]]["node_type"] == "needs_review"

View File

@@ -0,0 +1,65 @@
import uuid
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.account import Account
from app.models.flow_proposal import FlowProposal
from app.models.l1_walk_session import L1WalkSession
from app.models.user import User
def test_flow_proposal_accepts_l1_session_id_without_source_session():
p = FlowProposal(
account_id=uuid.uuid4(),
l1_session_id=uuid.uuid4(),
source_session_id=None,
proposal_type="new_flow",
title="AI L1 draft",
proposed_flow_data={"tree_structure": {"id": "root"}},
source="ai_realtime_l1",
status="pending",
)
assert p.l1_session_id is not None and p.source_session_id is None
@pytest.mark.asyncio
async def test_deleting_l1_session_cascades_proposal_not_check_violation(test_db: AsyncSession):
"""Finding 6: an L1-sourced proposal has source_session_id NULL by the exactly-one
CHECK. With ondelete=CASCADE the proposal dies with its session; the old SET NULL
would have NULLed both columns and aborted the DELETE on the CHECK (time bomb)."""
s = str(uuid.uuid4())[:8]
account = Account(id=uuid.uuid4(), name=f"Acct {s}", display_code=s.upper())
test_db.add(account)
await test_db.flush()
user = User(
id=uuid.uuid4(), email=f"u-{uuid.uuid4()}@example.com", name="U",
account_id=account.id, account_role="l1_tech", role="engineer", is_active=True,
)
test_db.add(user)
await test_db.flush()
session = L1WalkSession(
account_id=account.id, created_by_user_id=user.id,
ticket_id="t-cascade", ticket_kind="internal", session_kind="ai_build",
)
test_db.add(session)
await test_db.flush()
proposal = FlowProposal(
account_id=account.id, l1_session_id=session.id, source_session_id=None,
proposal_type="new_flow", title="AI L1 draft",
proposed_flow_data={"tree_structure": {"id": "root"}},
source="ai_realtime_l1", status="pending",
)
test_db.add(proposal)
await test_db.flush()
pid = proposal.id
# Delete the session — must succeed and cascade to the proposal.
await test_db.delete(session)
await test_db.flush()
remaining = (await test_db.execute(
select(FlowProposal).where(FlowProposal.id == pid)
)).scalar_one_or_none()
assert remaining is None

View File

@@ -0,0 +1,161 @@
"""End-to-end backend integration test for the L1 AI-build flow (Phase 2A).
Drives the real endpoint + service path — intake (build) → next-node walk →
resolve — and asserts an outcome-validated FlowProposal is captured. Only the AI
boundary is mocked: match_or_build's outcome and ai_tree_builder.generate_next_node.
A second test drives intake → escalate and asserts the engineer notification fires
and the session surfaces in GET /l1/escalations.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.flow_proposal import FlowProposal
from app.models.subscription import Subscription
from app.models.user import User
async def _register(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "password": "TestPassword123!", "name": "Test User"},
)
assert resp.status_code in (200, 201), resp.text
return resp.json()
async def _login(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": "TestPassword123!"},
)
assert resp.status_code == 200, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def _ensure_subscription(db: AsyncSession, account_id: uuid.UUID) -> None:
await db.execute(delete(Subscription).where(Subscription.account_id == account_id))
db.add(Subscription(account_id=account_id, plan="pro", status="active"))
await db.commit()
async def _make_user(client: AsyncClient, db: AsyncSession, *, email: str, account_role: str) -> dict:
data = await _register(client, email=email)
uid = uuid.UUID(data["id"])
acct_id = uuid.UUID(data["account_id"])
user = (await db.execute(select(User).where(User.id == uid))).scalar_one()
user.account_role = account_role
await db.commit()
await _ensure_subscription(db, acct_id)
headers = await _login(client, email=email)
return {"headers": headers, "account_id": acct_id, "user_id": uid}
@pytest.mark.asyncio
async def test_intake_build_walk_resolve_creates_proposal(client: AsyncClient, test_db: AsyncSession):
"""intake(build) → answer a question node → reach resolved → resolve → proposal."""
info = await _make_user(client, test_db, email="flow_resolve@example.com", account_role="l1_tech")
headers = info["headers"]
# 1. force a build outcome at intake (real ticket + ai_build session created)
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=headers)
assert r.status_code == 200, r.text
sid = r.json()["session_id"]
# 2. drive next-node deterministically: first a question, then a resolved terminal
seq = iter([
{"node_type": "question", "id": "n1", "text": "Is the printer powered on?"},
{"node_type": "resolved", "id": "n2", "text": "Printer prints a test page."},
])
async def fake_next(problem_text, category, walked_path):
return next(seq)
with patch("app.services.ai_tree_builder.generate_next_node", new=fake_next):
r1 = await client.post(f"/api/v1/l1/sessions/{sid}/next-node",
json={}, headers=headers)
assert r1.status_code == 200, r1.text
assert r1.json()["node"]["node_type"] == "question"
r2 = await client.post(
f"/api/v1/l1/sessions/{sid}/next-node",
json={"node_id": "n1", "node_text": "Is the printer powered on?", "answer": "no"},
headers=headers,
)
assert r2.status_code == 200, r2.text
assert r2.json()["node"]["node_type"] == "resolved"
# 3. resolve helpful → outcome-validated proposal captured
rr = await client.post(f"/api/v1/l1/sessions/{sid}/resolve",
json={"helpful": True, "resolution_notes": "Powered it on."},
headers=headers)
assert rr.status_code == 200, rr.text
assert rr.json()["status"] == "resolved"
props = (await test_db.execute(
select(FlowProposal).where(FlowProposal.source == "ai_realtime_l1")
)).scalars().all()
assert len(props) == 1
p = props[0]
assert p.validated_by_outcome is True
assert p.source_session_id is None
assert str(p.l1_session_id) == sid
# the walked question 'n1' becomes the captured tree root (meta entry skipped)
assert p.proposed_flow_data["tree_structure"]["id"] == "n1"
@pytest.mark.asyncio
async def test_intake_build_escalate_notifies_and_lists(client: AsyncClient, test_db: AsyncSession):
"""intake(build) → escalate → notify fires for engineers → appears in GET /escalations."""
# an engineer in the same account is the escalation recipient + the queue viewer
l1 = await _make_user(client, test_db, email="flow_esc_l1@example.com", account_role="l1_tech")
eng_data = await _register(client, email="flow_esc_eng@example.com")
eng_uid = uuid.UUID(eng_data["id"])
# put the engineer in the L1 tech's account
eng = (await test_db.execute(select(User).where(User.id == eng_uid))).scalar_one()
eng.account_id = l1["account_id"]
eng.account_role = "engineer"
await test_db.commit()
eng_headers = await _login(client, email="flow_esc_eng@example.com")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "weird driver fault"},
headers=l1["headers"])
assert r.status_code == 200, r.text
sid = r.json()["session_id"]
captured = {}
async def fake_notify(event, account_id, payload, db, target_user_ids=None):
captured["event"] = event
captured["target_user_ids"] = target_user_ids
with patch("app.services.l1_session_service.notify", new=fake_notify):
re_ = await client.post(f"/api/v1/l1/sessions/{sid}/escalate",
json={"reason_category": "exhausted_safe_steps",
"reason": "Beyond L1 scope"},
headers=l1["headers"])
assert re_.status_code == 200, re_.text
assert re_.json()["status"] == "escalated"
assert captured["event"] == "l1.session.escalated"
assert eng_uid in (captured["target_user_ids"] or [])
# engineer sees it in the escalations queue
q = await client.get("/api/v1/l1/escalations", headers=eng_headers)
assert q.status_code == 200, q.text
assert any(row["id"] == sid for row in q.json())

View File

@@ -0,0 +1,16 @@
import uuid
from app.models.l1_walk_session import L1WalkSession
def test_ai_build_session_kind_allowed_by_model_constraint():
"""ai_build is a valid session_kind with both target FKs null (like adhoc)."""
s = L1WalkSession(
account_id=uuid.uuid4(),
created_by_user_id=uuid.uuid4(),
ticket_id="t1",
ticket_kind="internal",
session_kind="ai_build",
)
assert s.session_kind == "ai_build"
assert s.flow_id is None and s.flow_proposal_id is None

View File

@@ -0,0 +1,227 @@
"""Integration tests for the Phase 2A L1 AI-build API surface.
Covers intake dispatch (match_or_build outcomes), the next-node endpoint, and the
engineer escalations list. The orchestrator and node generator are mocked — this
exercises the endpoint wiring, not the AI. Auth/subscription follow the same
register → promote-role → ensure-subscription → login pattern as test_l1_endpoints.
"""
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.l1_walk_session import L1WalkSession
from app.models.subscription import Subscription
from app.models.user import User
async def _register(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "password": "TestPassword123!", "name": "Test User"},
)
assert resp.status_code in (200, 201), resp.text
return resp.json()
async def _login(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": "TestPassword123!"},
)
assert resp.status_code == 200, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def _ensure_subscription(db: AsyncSession, account_id: uuid.UUID) -> None:
await db.execute(delete(Subscription).where(Subscription.account_id == account_id))
db.add(Subscription(account_id=account_id, plan="pro", status="active"))
await db.commit()
async def _make_user(client: AsyncClient, db: AsyncSession, *, email: str, account_role: str) -> dict:
"""Register a user, promote to a role, ensure an active subscription, return headers + ids."""
data = await _register(client, email=email)
uid = uuid.UUID(data["id"])
acct_id = uuid.UUID(data["account_id"])
user = (await db.execute(select(User).where(User.id == uid))).scalar_one()
user.account_role = account_role
await db.commit()
await _ensure_subscription(db, acct_id)
headers = await _login(client, email=email) # login AFTER role change
return {"headers": headers, "account_id": acct_id, "user_id": uid}
@pytest.mark.asyncio
async def test_intake_build_outcome_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
"""intake → match_or_build returns 'build' → an ai_build session is created."""
info = await _make_user(client, test_db, email="aib_build@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=info["headers"])
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "build"
assert body["session_kind"] == "ai_build"
assert body["session_id"]
@pytest.mark.asyncio
async def test_intake_out_of_scope(client: AsyncClient, test_db: AsyncSession):
"""intake → 'out_of_scope' → no session, surfaced to the caller."""
info = await _make_user(client, test_db, email="aib_oos@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "out_of_scope", "category": "unknown"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "weird"}, headers=info["headers"])
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "out_of_scope"
assert body.get("session_id") is None
@pytest.mark.asyncio
async def test_intake_suggest_returns_near_miss(client: AsyncClient, test_db: AsyncSession):
"""intake → 'suggest' → near_miss prompt, no session."""
info = await _make_user(client, test_db, email="aib_sugg@example.com", account_role="l1_tech")
near = {"flow_id": str(uuid.uuid4()), "flow_name": "VPN", "score": 0.66}
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "suggest", "near_miss": near, "can_build": True}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "vpn"}, headers=info["headers"])
assert r.status_code == 200, r.text
assert r.json()["near_miss"]["flow_name"] == "VPN"
@pytest.mark.asyncio
async def test_next_node_returns_generated_node(client: AsyncClient, test_db: AsyncSession):
"""After a build intake, /next-node returns the node from advance_ai_build."""
info = await _make_user(client, test_db, email="aib_next@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=info["headers"])
sid = r.json()["session_id"]
with patch(
"app.api.endpoints.l1.l1_session_service.advance_ai_build",
new=AsyncMock(return_value={"node_type": "question", "id": "n1", "text": "Powered on?"}),
):
r2 = await client.post(f"/api/v1/l1/sessions/{sid}/next-node",
json={}, headers=info["headers"])
assert r2.status_code == 200, r2.text
assert r2.json()["node"]["node_type"] == "question"
@pytest.mark.asyncio
async def test_escalations_lists_escalated_sessions_for_engineer(client: AsyncClient, test_db: AsyncSession):
"""GET /l1/escalations returns escalated L1 sessions for an engineer-or-above user."""
info = await _make_user(client, test_db, email="aib_eng@example.com", account_role="engineer")
now = datetime.now(timezone.utc)
sess = L1WalkSession(
account_id=info["account_id"],
created_by_user_id=info["user_id"],
ticket_id="t-esc",
ticket_kind="internal",
session_kind="ai_build",
status="escalated",
started_at=now,
last_step_at=now,
)
test_db.add(sess)
await test_db.commit()
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
assert r.status_code == 200, r.text
assert any(row["id"] == str(sess.id) for row in r.json())
@pytest.mark.asyncio
async def test_escalations_forbidden_for_l1_tech(client: AsyncClient, test_db: AsyncSession):
"""An l1_tech (not engineer-or-above) is rejected from the escalations queue."""
info = await _make_user(client, test_db, email="aib_l1@example.com", account_role="l1_tech")
r = await client.get("/api/v1/l1/escalations", headers=info["headers"])
assert r.status_code == 403, r.text
@pytest.mark.asyncio
async def test_intake_with_flow_id_starts_flow_directly(client: AsyncClient, test_db: AsyncSession):
"""Finding 4: an explicit flow_id bypasses the matcher and starts that flow."""
from app.models.tree import Tree
info = await _make_user(client, test_db, email="aib_flowid@example.com", account_role="l1_tech")
tree = Tree(
id=uuid.uuid4(), name="VPN Flow", account_id=info["account_id"],
author_id=info["user_id"], tree_type="troubleshooting",
tree_structure={"nodes": [], "edges": []}, visibility="team", status="published",
)
test_db.add(tree)
await test_db.commit()
# match_or_build must NOT be called when flow_id is supplied.
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(side_effect=AssertionError("matcher should be bypassed")),
):
r = await client.post(
"/api/v1/l1/intake",
json={"problem_statement": "vpn down", "flow_id": str(tree.id)},
headers=info["headers"],
)
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "matched"
assert body["session_kind"] == "flow"
assert body["flow_id"] == str(tree.id)
assert body["session_id"]
@pytest.mark.asyncio
async def test_intake_adhoc_starts_adhoc_session(client: AsyncClient, test_db: AsyncSession):
"""Finding 5: adhoc=True starts a free-form ad-hoc walk (out_of_scope fallback)."""
info = await _make_user(client, test_db, email="aib_adhoc@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(side_effect=AssertionError("matcher should be bypassed")),
):
r = await client.post(
"/api/v1/l1/intake",
json={"problem_statement": "weird thing", "adhoc": True},
headers=info["headers"],
)
assert r.status_code == 200, r.text
body = r.json()
assert body["outcome"] == "adhoc"
assert body["session_kind"] == "adhoc"
assert body["session_id"]
@pytest.mark.asyncio
async def test_intake_build_persists_category_and_problem_text(client: AsyncClient, test_db: AsyncSession):
"""Root cause B: build stores category + problem_text on the session (no meta entry)."""
info = await _make_user(client, test_db, email="aib_cols@example.com", account_role="l1_tech")
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
r = await client.post("/api/v1/l1/intake",
json={"problem_statement": "printer jam"}, headers=info["headers"])
sid = r.json()["session_id"]
sess = await test_db.get(L1WalkSession, uuid.UUID(sid))
assert sess.category == "printer"
assert sess.problem_text == "printer jam"
# No hidden meta entry smuggled into walked_path.
assert sess.walked_path == []

View File

@@ -0,0 +1,129 @@
"""Tests for the account L1 AI-build category settings API (Phase 2A).
GET /accounts/me/l1-categories — owner/admin only (Finding 7: read and write agree).
PATCH /accounts/me/l1-categories — owner/admin only; drops unknown/hard-floored keys.
"""
import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subscription import Subscription
from app.models.user import User
async def _register(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "password": "TestPassword123!", "name": "Test User"},
)
assert resp.status_code in (200, 201), resp.text
return resp.json()
async def _login(client: AsyncClient, *, email: str) -> dict:
resp = await client.post(
"/api/v1/auth/login/json",
json={"email": email, "password": "TestPassword123!"},
)
assert resp.status_code == 200, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
async def _ensure_subscription(db: AsyncSession, account_id: uuid.UUID) -> None:
await db.execute(delete(Subscription).where(Subscription.account_id == account_id))
db.add(Subscription(account_id=account_id, plan="pro", status="active"))
await db.commit()
async def _make_user(client: AsyncClient, db: AsyncSession, *, email: str, account_role: str) -> dict:
"""Register → promote role → ensure subscription → login (after the role change)."""
data = await _register(client, email=email)
uid = uuid.UUID(data["id"])
acct_id = uuid.UUID(data["account_id"])
user = (await db.execute(select(User).where(User.id == uid))).scalar_one()
user.account_role = account_role
await db.commit()
await _ensure_subscription(db, acct_id)
headers = await _login(client, email=email)
return {"headers": headers, "account_id": acct_id, "user_id": uid}
@pytest.mark.asyncio
async def test_get_categories_returns_enabled_available_hard_floor(client: AsyncClient, test_db: AsyncSession):
info = await _make_user(client, test_db, email="cat_owner_get@example.com", account_role="owner")
r = await client.get("/api/v1/accounts/me/l1-categories", headers=info["headers"])
assert r.status_code == 200, r.text
body = r.json()
assert "enabled" in body and "available" in body and "hard_floor" in body
# New account defaults to the full available allowlist (10 keys).
assert len(body["available"]) == 10
assert "password_reset" in body["available"]
assert "registry_edit" in body["hard_floor"]
@pytest.mark.asyncio
async def test_get_categories_readable_by_admin(client: AsyncClient, test_db: AsyncSession):
"""Finding 7: account admins can READ (previously 403 on GET while they could PATCH)."""
info = await _make_user(client, test_db, email="cat_admin_get@example.com", account_role="admin")
r = await client.get("/api/v1/accounts/me/l1-categories", headers=info["headers"])
assert r.status_code == 200, r.text
@pytest.mark.asyncio
async def test_get_categories_forbidden_for_l1_tech(client: AsyncClient, test_db: AsyncSession):
"""Finding 7: GET now matches PATCH (owner/admin only). The walker gates
server-side and never fetches this, so l1_tech read access was unused."""
info = await _make_user(client, test_db, email="cat_l1_get@example.com", account_role="l1_tech")
r = await client.get("/api/v1/accounts/me/l1-categories", headers=info["headers"])
assert r.status_code == 403, r.text
@pytest.mark.asyncio
async def test_patch_categories_owner_can_set(client: AsyncClient, test_db: AsyncSession):
info = await _make_user(client, test_db, email="cat_owner_patch@example.com", account_role="owner")
r = await client.patch(
"/api/v1/accounts/me/l1-categories",
json={"enabled": ["printer", "vpn_connect"]},
headers=info["headers"],
)
assert r.status_code == 200, r.text
assert set(r.json()["enabled"]) == {"printer", "vpn_connect"}
@pytest.mark.asyncio
async def test_patch_categories_drops_unknown_and_hard_floored(client: AsyncClient, test_db: AsyncSession):
info = await _make_user(client, test_db, email="cat_owner_drop@example.com", account_role="owner")
r = await client.patch(
"/api/v1/accounts/me/l1-categories",
json={"enabled": ["printer", "registry_edit", "bogus_key"]},
headers=info["headers"],
)
assert r.status_code == 200, r.text
# registry_edit (hard floor) and bogus_key (unknown) are dropped.
assert r.json()["enabled"] == ["printer"]
@pytest.mark.asyncio
async def test_patch_categories_forbidden_for_l1_tech(client: AsyncClient, test_db: AsyncSession):
info = await _make_user(client, test_db, email="cat_l1_patch@example.com", account_role="l1_tech")
r = await client.patch(
"/api/v1/accounts/me/l1-categories",
json={"enabled": ["printer"]},
headers=info["headers"],
)
assert r.status_code == 403, r.text
@pytest.mark.asyncio
async def test_patch_categories_forbidden_for_engineer(client: AsyncClient, test_db: AsyncSession):
"""Write is owner/admin only — engineers (who pass require_engineer_or_admin) are blocked."""
info = await _make_user(client, test_db, email="cat_eng_patch@example.com", account_role="engineer")
r = await client.patch(
"/api/v1/accounts/me/l1-categories",
json={"enabled": ["printer"]},
headers=info["headers"],
)
assert r.status_code == 403, r.text

View File

@@ -0,0 +1,16 @@
from app.services.l1_category_service import (
DEFAULT_L1_CATEGORIES, HARD_FLOOR_FORBIDDEN, is_category_enabled,
)
def test_defaults_and_hard_floor_present():
assert "password_reset" in DEFAULT_L1_CATEGORIES
assert "registry_edit" in HARD_FLOOR_FORBIDDEN # representative forbidden action key
assert len(DEFAULT_L1_CATEGORIES) == 10
def test_is_category_enabled():
enabled = ["printer", "vpn_connect"]
assert is_category_enabled("printer", enabled) is True
assert is_category_enabled("registry_edit", enabled) is False
assert is_category_enabled("unknown", enabled) is False

View File

@@ -82,16 +82,61 @@ async def _make_l1_user(
return {"user_data": {"id": str(user.id), "account_id": str(account_id)}, "headers": None} return {"user_data": {"id": str(user.id), "account_id": str(account_id)}, "headers": None}
async def _create_adhoc_session(db: AsyncSession, info: dict, *, problem: str = "setup") -> str:
"""Create an adhoc walk session (backed by a real internal ticket) via the service.
Phase 2A: POST /l1/intake dispatches through match_or_build and no longer
yields an adhoc session directly, so step/notes/resolve/escalate/cross-account
tests build their setup session here instead of through intake. The test
client shares this same DB session (conftest override_get_db), so the
committed session is visible to the API immediately.
"""
from sqlalchemy import select as sa_select
from app.services import internal_ticket_service, l1_session_service
account_id = info["account_id"]
user_id = uuid.UUID(info["user_data"]["id"])
user = (await db.execute(sa_select(User).where(User.id == user_id))).scalar_one()
ticket = await internal_ticket_service.create_ticket(
db,
account_id=account_id,
created_by_user_id=user_id,
problem_statement=problem,
customer_name=None,
customer_contact=None,
)
session = await l1_session_service.start_adhoc_session(
db,
account_id=account_id,
user=user,
ticket_id=str(ticket.id),
ticket_kind="internal",
)
await db.commit()
return str(session.id)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Intake without flow_id → 200 + session_kind='adhoc' # 1. Intake (Phase 2A): build outcome → 200 + session_kind='ai_build'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_intake_adhoc(client: AsyncClient, test_db: AsyncSession): async def test_intake_build_creates_ai_build_session(client: AsyncClient, test_db: AsyncSession):
"""POST /l1/intake without flow_id creates adhoc session.""" """POST /l1/intake with a 'build' outcome creates an ai_build session.
Phase 2A: intake dispatches via match_or_build. An explicit adhoc=True (the
out_of_scope prompt's "Walk it ad-hoc") starts an ad-hoc session directly —
see test_l1_api_ai_build.test_intake_adhoc_starts_adhoc_session.
"""
from unittest.mock import AsyncMock, patch
info = await _make_l1_user(client, test_db, email="l1intake@example.com") info = await _make_l1_user(client, test_db, email="l1intake@example.com")
headers = info["headers"] headers = info["headers"]
with patch(
"app.api.endpoints.l1.match_or_build.match_or_build",
new=AsyncMock(return_value={"outcome": "build", "session_kind": "ai_build",
"category": "printer"}),
):
resp = await client.post( resp = await client.post(
"/api/v1/l1/intake", "/api/v1/l1/intake",
json={"problem_statement": "Printer won't turn on", "customer_name": "Alice"}, json={"problem_statement": "Printer won't turn on", "customer_name": "Alice"},
@@ -99,10 +144,11 @@ async def test_intake_adhoc(client: AsyncClient, test_db: AsyncSession):
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
body = resp.json() body = resp.json()
assert body["session_kind"] == "adhoc" assert body["outcome"] == "build"
assert body["session_kind"] == "ai_build"
assert body["ticket_kind"] == "internal" assert body["ticket_kind"] == "internal"
assert "session_id" in body assert body["session_id"]
assert "ticket_id" in body assert body["ticket_id"]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -156,14 +202,7 @@ async def test_step_on_adhoc_returns_400(client: AsyncClient, test_db: AsyncSess
info = await _make_l1_user(client, test_db, email="l1step@example.com") info = await _make_l1_user(client, test_db, email="l1step@example.com")
headers = info["headers"] headers = info["headers"]
# Create adhoc session via intake session_id = await _create_adhoc_session(test_db, info, problem="Adhoc issue")
resp = await client.post(
"/api/v1/l1/intake",
json={"problem_statement": "Adhoc issue"},
headers=headers,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
resp = await client.post( resp = await client.post(
f"/api/v1/l1/sessions/{session_id}/step", f"/api/v1/l1/sessions/{session_id}/step",
@@ -184,13 +223,7 @@ async def test_notes_on_adhoc_session(client: AsyncClient, test_db: AsyncSession
info = await _make_l1_user(client, test_db, email="l1notes@example.com") info = await _make_l1_user(client, test_db, email="l1notes@example.com")
headers = info["headers"] headers = info["headers"]
resp = await client.post( session_id = await _create_adhoc_session(test_db, info, problem="Notes test")
"/api/v1/l1/intake",
json={"problem_statement": "Notes test"},
headers=headers,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
notes_payload = [{"text": "Customer called about printer", "ts": "2026-05-28T10:00:00Z"}] notes_payload = [{"text": "Customer called about printer", "ts": "2026-05-28T10:00:00Z"}]
resp = await client.post( resp = await client.post(
@@ -213,13 +246,7 @@ async def test_resolve_session(client: AsyncClient, test_db: AsyncSession):
info = await _make_l1_user(client, test_db, email="l1resolve@example.com") info = await _make_l1_user(client, test_db, email="l1resolve@example.com")
headers = info["headers"] headers = info["headers"]
resp = await client.post( session_id = await _create_adhoc_session(test_db, info, problem="Resolve test")
"/api/v1/l1/intake",
json={"problem_statement": "Resolve test"},
headers=headers,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
resp = await client.post( resp = await client.post(
f"/api/v1/l1/sessions/{session_id}/resolve", f"/api/v1/l1/sessions/{session_id}/resolve",
@@ -245,13 +272,7 @@ async def test_escalate_session(client: AsyncClient, test_db: AsyncSession):
info = await _make_l1_user(client, test_db, email="l1escalate@example.com") info = await _make_l1_user(client, test_db, email="l1escalate@example.com")
headers = info["headers"] headers = info["headers"]
resp = await client.post( session_id = await _create_adhoc_session(test_db, info, problem="Escalation test")
"/api/v1/l1/intake",
json={"problem_statement": "Escalation test"},
headers=headers,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
resp = await client.post( resp = await client.post(
f"/api/v1/l1/sessions/{session_id}/escalate", f"/api/v1/l1/sessions/{session_id}/escalate",
@@ -344,15 +365,8 @@ async def test_get_session_cross_account_returns_404(client: AsyncClient, test_d
"""GET /l1/sessions/{id} from a different account → 404.""" """GET /l1/sessions/{id} from a different account → 404."""
# Account A: creates a session # Account A: creates a session
info_a = await _make_l1_user(client, test_db, email="l1tenanta@example.com") info_a = await _make_l1_user(client, test_db, email="l1tenanta@example.com")
headers_a = info_a["headers"]
resp = await client.post( session_id = await _create_adhoc_session(test_db, info_a, problem="Account A issue")
"/api/v1/l1/intake",
json={"problem_statement": "Account A issue"},
headers=headers_a,
)
assert resp.status_code == 200, resp.text
session_id = resp.json()["session_id"]
# Account B: different user in a different account # Account B: different user in a different account
info_b = await _make_l1_user(client, test_db, email="l1tenantb@example.com") info_b = await _make_l1_user(client, test_db, email="l1tenantb@example.com")

View File

@@ -11,6 +11,7 @@ from app.models.user import User
from app.models.tree import Tree from app.models.tree import Tree
from app.models.ai_session import AISession from app.models.ai_session import AISession
from app.models.flow_proposal import FlowProposal from app.models.flow_proposal import FlowProposal
from app.models.l1_walk_session import L1WalkSession
from app.services.l1_session_service import ( from app.services.l1_session_service import (
start_flow_session, start_flow_session,
start_proposal_session, start_proposal_session,
@@ -778,6 +779,165 @@ async def test_escalate_without_walk_reason_is_optional(test_db: AsyncSession):
assert session.escalation_reason_category == "no_kb_content" assert session.escalation_reason_category == "no_kb_content"
# ---------------------------------------------------------------------------
# T7: start_ai_build_session
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_ai_build_session(test_db: AsyncSession):
from app.services import l1_session_service as svc
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-ai", ticket_kind="internal",
)
assert s.session_kind == "ai_build"
assert s.flow_id is None and s.flow_proposal_id is None
assert s.status == "active"
# ---------------------------------------------------------------------------
# T8: advance_ai_build
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_advance_ai_build_appends_and_returns_next(test_db: AsyncSession, monkeypatch):
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-ai", ticket_kind="internal")
async def fake_next(problem, category, walked):
return {"node_type": "resolved", "id": "done", "text": "Fixed."}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
next_node = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer", category="printer",
node_id="n1", node_text="Powered on?", answer="no", note=None)
assert next_node["node_type"] == "resolved"
refreshed = await test_db.get(type(s), s.id)
assert len(refreshed.walked_path) == 1
assert refreshed.walked_path[0]["answer"] == "no"
assert refreshed.walked_path[0]["text"] == "Powered on?"
@pytest.mark.asyncio
async def test_advance_ai_build_first_call_does_not_append(test_db: AsyncSession, monkeypatch):
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-ai-first", ticket_kind="internal")
async def fake_next(problem, category, walked):
return {"node_type": "question", "id": "q1", "text": "Is it plugged in?"}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
# First call: node_id=None — nothing should be appended
next_node = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer", category="printer",
node_id=None)
assert next_node["node_type"] == "question"
assert next_node["id"] == "q1"
refreshed = await test_db.get(type(s), s.id)
assert len(refreshed.walked_path) == 0
assert refreshed.current_node_id == "q1"
@pytest.mark.asyncio
async def test_advance_ai_build_wrong_session_kind_raises(test_db: AsyncSession, monkeypatch):
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
# start an adhoc session (not ai_build)
s = await svc.start_adhoc_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-adhoc-guard", ticket_kind="internal")
async def fake_next(problem, category, walked): # pragma: no cover
return {"node_type": "question", "id": "q1", "text": "?"}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
with pytest.raises(ValueError, match="ai_build"):
await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer", category="printer")
# ---------------------------------------------------------------------------
# T9: flywheel capture on resolve + engineer notification on escalate
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_ai_build_creates_outcome_validated_proposal(test_db: AsyncSession, monkeypatch):
"""resolve(helpful=True) on an ai_build session creates a FlowProposal with validated_by_outcome=True."""
from app.services import l1_session_service as svc
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1_user.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id=str(ticket.id), ticket_kind="internal",
)
# Populate walked_path with at least one node (needed for normalize_walked_path)
s.walked_path = [
{"node_type": "question", "id": "n1", "text": "On?", "answer": "no"},
{"node_type": "resolved", "id": "n2", "text": "Fixed."},
]
await test_db.flush()
await svc.resolve(test_db, session_id=s.id, helpful=True, resolution_notes="ok")
props = (await test_db.execute(
select(FlowProposal).where(FlowProposal.l1_session_id == s.id)
)).scalars().all()
assert len(props) == 1
assert props[0].source == "ai_realtime_l1"
assert props[0].validated_by_outcome is True
assert props[0].source_session_id is None
assert props[0].proposed_flow_data["tree_structure"]["id"] == "n1"
assert props[0].proposal_type == "new_flow"
assert props[0].proposed_flow_data["match_keywords"] == []
@pytest.mark.asyncio
async def test_escalate_notifies_engineers(test_db: AsyncSession, monkeypatch):
"""escalate() calls notify with event='l1.session.escalated' and explicit engineer recipients."""
from app.services import l1_session_service as svc
calls = {}
async def fake_notify(event, account_id, payload, db, target_user_ids=None):
calls["event"] = event
calls["target_user_ids"] = target_user_ids
monkeypatch.setattr(svc, "notify", fake_notify)
account = await _make_account(test_db)
# l1_user is the session owner (account_role="l1_tech" by default — NOT in the recipient query)
l1_user = await _make_user(test_db, account_id=account.id)
# Seed an eligible recipient: account_role="engineer" matches the production query
# (owner/admin/engineer). Without this user, target_ids would be [] and the
# eng.id assertion below would fail, proving the assertion is non-vacuous.
eng = await _make_user(test_db, account_id=account.id, account_role="engineer")
ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1_user.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id=str(ticket.id), ticket_kind="internal",
)
await svc.escalate(test_db, session_id=s.id, reason="stuck", reason_category="exhausted_safe_steps")
assert calls["event"] == "l1.session.escalated"
assert isinstance(calls["target_user_ids"], list) and len(calls["target_user_ids"]) >= 1
assert eng.id in calls["target_user_ids"] # the eligible engineer is a recipient
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# T14 audit log tests (spec §5.6.1) # T14 audit log tests (spec §5.6.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -914,4 +1074,173 @@ async def test_escalate_without_walk_writes_audit_log(test_db: AsyncSession):
) )
row = result.scalar_one() row = result.scalar_one()
assert row.account_id == account.id assert row.account_id == account.id
# Audit coverage: the reason category must be recorded (restored — a prior
# edit dropped this assertion, weakening the audit guarantee).
assert row.details["escalation_reason_category"] == "no_kb_content" assert row.details["escalation_reason_category"] == "no_kb_content"
# ---------------------------------------------------------------------------
# Finding 1 (server-assigned node ids) + Finding 8 (pending-node replay)
# ---------------------------------------------------------------------------
class _FakeProvider:
def __init__(self, raw):
self._raw = raw
async def generate_json(self, *, system_prompt, messages, max_tokens):
return self._raw, None, None
@pytest.mark.asyncio
async def test_ai_build_first_node_carries_id_and_advance_grows_walk(
test_db: AsyncSession, monkeypatch,
):
"""Finding 1 contract: the SYSTEM_PROMPT never asks for an id, yet the first
generated node must carry one — and advancing with that id must grow walked_path
(the original showstopper: node_id was always None, so the walk never advanced)."""
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-contract", ticket_kind="internal",
category="printer", problem_text="printer offline")
# Real generator + a provider that omits id (the shape the model produces).
monkeypatch.setattr(
ai_tree_builder, "get_ai_provider",
lambda *a, **k: _FakeProvider('{"node_type":"question","text":"Plugged in?"}'))
first = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer offline",
category="printer", node_id=None)
assert first.get("id"), "first node must carry a server-assigned id"
# Answer it with the id we were handed; walked_path must grow by one.
await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="printer offline", category="printer",
node_id=first["id"], node_text=first["text"], answer="no")
refreshed = await test_db.get(L1WalkSession, s.id)
assert len(refreshed.walked_path) == 1
assert refreshed.walked_path[0]["id"] == first["id"]
@pytest.mark.asyncio
async def test_advance_ai_build_replays_pending_node_without_regenerating(
test_db: AsyncSession, monkeypatch,
):
"""Finding 8: a re-mount (node_id=None) replays the served-but-unanswered node
instead of firing a fresh paid LLM call (which could also swap the question)."""
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-replay", ticket_kind="internal",
category="printer", problem_text="printer offline")
calls = {"n": 0}
async def fake_next(problem, category, walked):
calls["n"] += 1
return {"node_type": "question", "id": f"q{calls['n']}", "text": "?"}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
first = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="p", category="printer", node_id=None)
# Re-mount without answering — must NOT regenerate.
replay = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="p", category="printer", node_id=None)
assert calls["n"] == 1
assert replay["id"] == first["id"]
@pytest.mark.asyncio
async def test_advance_ai_build_records_answer_label_from_pending_node(
test_db: AsyncSession, monkeypatch,
):
"""When the served question carried yes_label/no_label, answering it must
record the chosen label (answer_label) in walked_path — derived server-side
from pending_node, never trusted from the client. 'Microsoft account or
local account? -> yes' is meaningless in the transcript and the LLM context."""
from app.services import l1_session_service as svc
from app.services import ai_tree_builder
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id="t-label", ticket_kind="internal",
category="account_login", problem_text="login issue")
async def fake_next(problem, category, walked):
return {"node_type": "question", "id": "q-acct",
"text": "Is the account a Microsoft account or a local account?",
"yes_label": "Microsoft account", "no_label": "Local account"}
monkeypatch.setattr(ai_tree_builder, "generate_next_node", fake_next)
first = await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="login issue",
category="account_login", node_id=None)
await svc.advance_ai_build(
test_db, session_id=s.id, problem_text="login issue",
category="account_login",
node_id=first["id"], node_text=first["text"], answer="yes")
refreshed = await test_db.get(L1WalkSession, s.id)
assert refreshed.walked_path[0]["answer"] == "yes"
assert refreshed.walked_path[0]["answer_label"] == "Microsoft account"
# ---------------------------------------------------------------------------
# Finding 10: escalation recipient resolution
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_escalate_skips_soft_deleted_engineer(test_db: AsyncSession, monkeypatch):
"""A soft-deleted engineer must not be paged (is_active alone misses them)."""
from datetime import datetime, timezone
from app.services import l1_session_service as svc
calls = {}
async def fake_notify(event, account_id, payload, db, target_user_ids=None):
calls["target_user_ids"] = target_user_ids
monkeypatch.setattr(svc, "notify", fake_notify)
account = await _make_account(test_db)
l1_user = await _make_user(test_db, account_id=account.id)
live_eng = await _make_user(test_db, account_id=account.id, account_role="engineer")
dead_eng = await _make_user(test_db, account_id=account.id, account_role="engineer")
dead_eng.deleted_at = datetime.now(timezone.utc)
await test_db.flush()
ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1_user.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id=str(ticket.id), ticket_kind="internal")
await svc.escalate(test_db, session_id=s.id, reason="x", reason_category="exhausted_safe_steps")
assert live_eng.id in calls["target_user_ids"]
assert dead_eng.id not in calls["target_user_ids"]
@pytest.mark.asyncio
async def test_escalate_with_no_engineers_falls_back_to_default_recipients(
test_db: AsyncSession, monkeypatch,
):
"""Finding 10: when no eligible engineer exists, pass None (not []) so notify()
falls back to the default owner/admin set instead of silently dropping it."""
from app.services import l1_session_service as svc
calls = {}
async def fake_notify(event, account_id, payload, db, target_user_ids=None):
calls["target_user_ids"] = target_user_ids
monkeypatch.setattr(svc, "notify", fake_notify)
account = await _make_account(test_db)
# Only an l1_tech exists — not in the owner/admin/engineer recipient query.
l1_user = await _make_user(test_db, account_id=account.id)
ticket = await _make_internal_ticket(test_db, account_id=account.id, user_id=l1_user.id)
s = await svc.start_ai_build_session(
test_db, account_id=account.id, user=l1_user,
ticket_id=str(ticket.id), ticket_kind="internal")
await svc.escalate(test_db, session_id=s.id, reason="x", reason_category="exhausted_safe_steps")
assert calls["target_user_ids"] is None

View File

@@ -0,0 +1,98 @@
import uuid
import pytest
from unittest.mock import AsyncMock, patch
from app.services import match_or_build as mob
@pytest.mark.asyncio
async def test_match_wins_before_category_gate():
"""A strong published-flow match returns 'matched' even if category disabled."""
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "VPN", "score": 0.9}])), \
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=[])):
res = await mob.match_or_build(uuid.uuid4(), "vpn down", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "matched"
assert res["session_kind"] == "flow"
@pytest.mark.asyncio
async def test_suggest_band():
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.66}])):
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "suggest"
assert res["near_miss"]["flow_name"] == "X"
assert "flow_id" in res["near_miss"] and isinstance(res["near_miss"]["flow_id"], str)
assert res["near_miss"]["score"] == 0.66
assert res["can_build"] is True
@pytest.mark.asyncio
async def test_out_of_scope_when_category_disabled_on_build_path():
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(return_value=[])), \
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["vpn_connect"])):
res = await mob.match_or_build(uuid.uuid4(), "printer jam", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "out_of_scope"
@pytest.mark.asyncio
async def test_build_when_enabled_and_no_match():
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(return_value=[])), \
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["printer"])):
res = await mob.match_or_build(uuid.uuid4(), "printer jam", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "build"
assert res["session_kind"] == "ai_build"
@pytest.mark.asyncio
async def test_force_build_skips_match_but_still_gates():
fm = AsyncMock(return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.99}])
with patch.object(mob.flow_matching_engine, "find_matches", new=fm), \
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["printer"])):
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=True)
fm.assert_not_called()
assert res["outcome"] == "build"
@pytest.mark.asyncio
async def test_score_exactly_match_threshold_is_matched():
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.75}])):
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "matched"
@pytest.mark.asyncio
async def test_score_exactly_suggest_threshold_is_suggest():
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.60}])):
res = await mob.match_or_build(uuid.uuid4(), "p", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "suggest"
@pytest.mark.asyncio
async def test_score_below_suggest_falls_through_to_build_path():
with patch.object(mob.flow_matching_engine, "find_matches", new=AsyncMock(
return_value=[{"tree_id": str(uuid.uuid4()), "tree_name": "X", "score": 0.4}])), \
patch.object(mob, "classify", new=AsyncMock(return_value="printer")), \
patch.object(mob, "get_enabled_categories", new=AsyncMock(return_value=["printer"])):
res = await mob.match_or_build(uuid.uuid4(), "printer", None, db=AsyncMock(), force_build=False)
assert res["outcome"] == "build"
@pytest.mark.asyncio
async def test_classify_keyword_fallback_matches_word():
with patch.object(mob, "get_ai_provider", side_effect=RuntimeError("model down")):
cat = await mob.classify("the printer is jammed")
assert cat == "printer"
@pytest.mark.asyncio
async def test_classify_keyword_fallback_no_substring_false_match():
# "have" must NOT match teams_zoom_av via the 'av' token; no real category word present
with patch.object(mob, "get_ai_provider", side_effect=RuntimeError("model down")):
cat = await mob.classify("i have a general question")
assert cat == "unknown"

View File

@@ -0,0 +1,180 @@
# PR #193 (Phase 2A — L1 AI Tree Builder) Review Findings
**Date:** 2026-06-09
**Reviewed:** `feat/l1-ai-tree-builder-phase-2a` vs `main` (42 files, +2,326/154)
**Process:** 7 independent finder angles, every candidate independently verified against actual code (quoted lines confirmed, not speculation).
**Verdict: DO NOT MERGE as-is.** The headline feature (AI-guided walkthrough) is non-functional end-to-end, two tasks recorded as complete in `.ai/HANDOFF.md` were never actually committed, and one DB constraint is a deletion time bomb.
---
## ✅ RESOLUTION (2026-06-09, same day)
**All 10 findings resolved.** Two architectural decisions taken (see `.ai/DECISIONS.md`):
the **root fix** for Findings 8/9 (real `category` / `problem_text` / `pending_node`
columns on `l1_walk_sessions`; the `{"node_type":"meta"}` walked_path convention
deleted entirely — migration `61dda4f615c6`), and **restoring the ad-hoc walk**
(Finding 5 option a — `adhoc=True` intake + "Walk it ad-hoc" out_of_scope button).
- **Finding 1** — `ai_tree_builder._assign_id` stamps `uuid4().hex[:8]` on every node
(generated, depth-cap, generation-failed); `current_node_id` now real. Contract test
added (`test_ai_build_first_node_carries_id_and_advance_grows_walk`).
- **Finding 2a/3** — `L1EscalationsSection` mounted on `EscalationQueuePage`;
`ProposalDetail` `/pilot` link gated on `source_session_id`, L1-source block added.
- **Finding 2b** — renders `step.question ?? step.text`, `timeAgo`, shows `problem_text`.
- **Finding 4** — intake honors explicit `flow_id` (matcher bypassed); suggest card passes
`near_miss.flow_id`; the three intake handlers collapsed into one `runIntake`.
- **Finding 5** — ad-hoc walk restored (option a).
- **Finding 6** — `l1_session_id` FK → `ondelete=CASCADE` (model + migration); cascade-delete test.
- **Finding 7** — owner+admin at all three layers (GET dep, route guard, `usePermissions`);
`require_account_owner_or_admin` delegates to `User.can_manage_account`; `User.account_role`
TS type gains `'admin'`.
- **Finding 8** — `pending_node` column; `/next-node` replays the served node on re-mount
(no duplicate paid generation); reads context off the session (no ticket re-fetch).
- **Finding 9** — meta entry gone → empty walk is falsy (no junk proposal) and the depth
cap counts only real steps.
- **Finding 10** — `escalate` passes `target_ids or None` (default fallback), filters
`deleted_at IS NULL`, warns when empty; two tests.
- **Cleanups** — dead `ticket_ref` deleted, `IntakeResponse` per-outcome validator + `ticket_kind`
Literal restored, unused `acknowledged` dropped, escalations partial index added, restored the
deleted `no_kb_content` audit assertion.
**Verification:** full Phase 2A backend set **110 passed / 0 failed**; frontend `tsc -b` +
`eslint` + `vite build` clean; migration upgrade→downgrade→upgrade roundtrip clean
(columns + FK `confdeltype` + partial index confirmed); anti-parrot guardrail green.
How to use this file: work the findings in order. Findings 17 are merge blockers; 810 can be fast-follows. Each finding lists the verified evidence (file:line) and a suggested fix. Several findings share two root causes — fix those at the root rather than patching symptoms:
- **Root cause A:** AI-generated nodes have no `id`, but the advance protocol keys on `node_id`. (Finding 1; touches 8.)
- **Root cause B:** The intake category is smuggled into `walked_path` as a fake `{"node_type":"meta"}` entry that every consumer must know to skip — and most don't. (Findings 2b, 9; the deeper fix is a real `category` column on `l1_walk_sessions`, plus `problem_text` while you're there — see Finding 8's note.)
---
## MERGE BLOCKERS
### 1. AI walkthrough can never advance past the first question (showstopper)
**Evidence:**
- `backend/app/services/ai_tree_builder.py:37-41` — SYSTEM_PROMPT's JSON output shapes (`{"node_type":"question","text":...}` etc.) define **no `id` field**; `validate_node` (lines 71-79) returns the node unchanged; nothing anywhere assigns an id.
- `backend/app/services/l1_session_service.py:156``advance_ai_build` only appends to `walked_path` `if node_id is not None`; docstring (line 139) says "On the first call (node_id is None) nothing is appended."
- `frontend/src/components/l1/L1WalkTreeVariant.tsx:52-54` — sends `node_id: node.id`, which is `undefined` at runtime (server never sends an id; `JSON.stringify` drops undefined keys) → backend always receives `node_id=None`.
- `l1_session_service.py:174``session.current_node_id = next_node.get("id")` is always `None`.
**User impact:** Tech answers the first question → answer is discarded, the same (or a re-rolled) first question regenerates forever. `walked_path` never grows past the meta entry, the depth cap never fires, and resolve captures an empty tree.
**Why tests missed it:** `test_l1_api_ai_build` and friends mock `advance_ai_build` / hand-craft nodes **with** `id` keys — a shape the real model is never instructed to produce.
**Fix:** Assign a server-side id to every generated node before returning it (e.g., `uuid4().hex[:8]` in `generate_next_node` after `validate_node`), persist it as `session.current_node_id`, and add a test that runs the real (unmocked-shape) prompt contract: generate → assert node has id → advance with that id → assert walked_path grew. Do NOT ask the LLM to invent ids.
### 2. Escalations from AI sessions go nowhere (two linked defects)
**2a — Component never mounted.**
- `grep -rn "L1EscalationsSection" frontend/src` → exactly one hit: its own definition (`frontend/src/components/l1/L1EscalationsSection.tsx:10`). It is imported nowhere.
- `frontend/src/pages/EscalationQueuePage.tsx:3` imports only `EscalationQueue, EscalationMetricCard` from `@/components/flowpilot`.
- `backend/app/services/notification_service.py:449``"l1.session.escalated": "/escalations"` deep-link → `frontend/src/router.tsx:299` renders `EscalationQueuePage` → engineer sees only FlowPilot escalations; the L1 handoff is invisible. `GET /l1/escalations` (`backend/app/api/endpoints/l1.py:330`) has no UI surface.
- **Note:** `.ai/HANDOFF.md:38` claims "L1EscalationsSection on EscalationQueuePage" — that claim is false (documentation drift; see Finding 3).
**2b — Component renders wrong fields once mounted.**
- `backend/app/services/l1_session_service.py:162-168` — ai_build entries are `{"node_type", "id", "text", "answer", "l1_note"}` (key is `text`); legacy `record_step` (lines 199-204) uses `question`/`node_id`.
- `L1EscalationsSection.tsx:61` renders `{step.question}` → blank for every ai_build entry.
- `L1EscalationsSection.tsx:46``{s.walked_path.length} steps walked` counts the hidden meta entry → "N+1 steps walked".
- `backend/app/api/endpoints/l1.py:41``_to_response` returns `walked_path` raw (meta entry included).
**Fix:** Mount `L1EscalationsSection` on `EscalationQueuePage` (or fold L1 rows into the existing queue). Render `step.question ?? step.text`. Filter `node_type === 'meta'` entries — ideally server-side in `_to_response` (or eliminate the meta entry entirely per Root cause B). Also use the shared `timeAgo` util (`frontend/src/lib/timeAgo.ts`) instead of `new Date(...).toLocaleString()` at line 50, to match every sibling queue.
### 3. Two tasks recorded as complete were never committed
- Task 16 ("ProposalDetail L1-source block") and Task 17 (mounting the escalations section) appear in `.ai/HANDOFF.md` / `SESSION_LOG.md` as done, but **no hunk for `ProposalDetail.tsx` exists in the diff**, and Finding 2a proves the mount never happened.
- Concrete user impact today: `frontend/src/components/flowpilot/ProposalDetail.tsx:91-101` renders the "Source Session" card unconditionally; line 95 is `` to={`/pilot/${proposal.source_session_id}`} `` with no null guard. L1-sourced proposals (created with `source_session_id=None`, `l1_session_id=<session>`) reach the review queue as `pending` → engineers get a broken **`/pilot/null`** link.
**Fix:** Implement the missing work: in `ProposalDetail.tsx`, gate the `/pilot/` link on `source_session_id != null` and render an L1-source block (problem statement, category, link to the L1 session / escalations view) when `l1_session_id` is set. The backend already serves `l1_session_id` via `backend/app/schemas/flow_proposal.py`. Then correct `.ai/HANDOFF.md`.
### 4. "Use this flow" button silently does nothing
- `frontend/src/pages/l1/L1Dashboard.tsx:77-86``useSuggestedFlow` re-POSTs `/l1/intake` with the same text, no `flow_id`. The in-code comment ("it matches again and returns a `matched` outcome") is factually wrong: the same text scores in the same 0.600.75 suggest band (`backend/app/services/match_or_build.py:66-72`, `MATCH_THRESHOLD = 0.75` line 21) → `suggest` again, no `session_id` → handler falls to `resetPrompts()` and the card vanishes. The suggested flow can never be started.
- `backend/app/api/endpoints/l1.py` — the rewritten intake **never reads `payload.flow_id`** (old branch deleted, diff confirms); `IntakeRequest.flow_id` (`backend/app/schemas/l1.py:13`) is now dead.
**Fix:** Make intake honor an explicit `flow_id` (bypass the matcher, call `start_flow_session` directly — restores the deleted behavior), and have the suggest card pass `near_miss.flow_id`. This also kills the wasteful re-run of the embedding + pgvector + keyword pipeline just to rediscover a flow_id the client already holds.
### 5. Out-of-scope problems lost the ad-hoc walk fallback
- Old intake had `else: start_adhoc_session(...)`; the rewrite (`backend/app/api/endpoints/l1.py:88-102`) dispatches only matched/build/suggest/out_of_scope. `start_adhoc_session` (`l1_session_service.py:82`) now has **zero callers** — ad-hoc sessions are unreachable product-wide (the only remaining `session_kind="adhoc"` creation is `escalate_without_walk`, an audit record, not walkable).
- `L1Dashboard.tsx:269-292` — out_of_scope prompt offers only "Escalate to engineering" / "Cancel".
- Stale copy: `frontend/src/pages/account/L1CategoriesPage.tsx:57-58` still promises "Disabled categories fall back to an ad-hoc walk or escalation." A diff test comment also claims adhoc "is offered from the out_of_scope prompt" — it is not.
**Fix (decide deliberately, don't drift):** Either (a) add a "Walk it ad-hoc" option to the out_of_scope prompt that hits a path creating an adhoc session (restore the capability), or (b) if dropping ad-hoc is intentional, fix the L1CategoriesPage copy and the test comment, and note the decision in `.ai/DECISIONS.md`. Option (a) preserves pre-existing user capability; recommend (a).
### 6. DB constraint makes L1-session deletion always fail (time bomb)
- `backend/app/models/flow_proposal.py:60-63``CheckConstraint("(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)")` (XOR).
- Line 87-92 — `l1_session_id` FK is `ondelete="SET NULL"`; `source_session_id` (line 83) is `ondelete="CASCADE"`.
- Migration `backend/alembic/versions/1fd88a68b145_flow_proposal_l1_source_linkage.py:32-45` ships the same DDL.
- Postgres CHECK constraints are non-deferrable and ARE evaluated on the UPDATE produced by `ON DELETE SET NULL`. So deleting any `l1_walk_sessions` row referenced by a proposal (whose `source_session_id` is NULL by construction) → both columns NULL → CHECK violation → the DELETE fails. The `SET NULL` action can literally never fire successfully.
- Reachable today via `backend/app/api/endpoints/admin.py:1336` (`hard_delete_user``db.delete(account)`, DB-side cascades with unspecified ordering), and via any future GDPR/retention purge.
**Fix:** Change `l1_session_id` to `ondelete="CASCADE"` (matching `source_session_id`'s behavior — proposal dies with its source), in both the model and a new migration. Keep the XOR check. Alternative (`num_nonnulls(...) >= 1` style relaxation) is weaker; prefer CASCADE.
### 7. Account admins locked out of L1 category settings (3-layer inconsistency)
- Frontend route: `frontend/src/router.tsx:368-372``requiredRole="owner"`; `frontend/src/hooks/usePermissions.ts:21-28` (`getEffectiveRole`) has **no admin branch**`account_role='admin'` maps to `viewer` → bounced to /trees.
- Backend GET: `backend/app/api/endpoints/accounts.py:175-178` uses `require_l1_or_above` (`deps.py:235-242`: `l1_tech/engineer/owner` only) → admin gets **403 on read**.
- Backend PATCH: `accounts.py:193-197` uses `require_account_owner_or_admin` (`deps.py:279-289`) → admin **can write**.
- `admin` is a real role: `backend/app/models/user.py:25` CHECK constraint; `user.py:132` treats admin as account-manager.
**Fix:** Pick one rule — owner+admin manage L1 categories — and apply it at all three layers: GET should use `require_account_owner_or_admin` too (or a combined dep), and the route guard needs admins to pass (either add an admin branch to `getEffectiveRole` — check blast radius on other `requiredRole` uses first — or a dedicated `canManageAccount`-style guard for this route). Also note `require_account_owner_or_admin` duplicates `User.can_manage_account` (`user.py:130-132`); delegate to it.
---
## FAST-FOLLOWS (real bugs, lower urgency)
### 8. Every walk-view mount fires a fresh paid LLM call (and may swap the question)
- The served-but-unanswered node is never persisted: `l1_session_service.py:156-174``node_id is None` path goes straight to `generate_next_node`; only `current_node_id` (always None today, see Finding 1) is stored. No replay branch.
- `L1WalkTreeVariant.tsx:26-44` — mount effect unconditionally POSTs `/next-node {}`; `frontend/src/main.tsx:4,34` — StrictMode is on, so dev double-mounts double-generate.
**Impact:** Refresh/back-forward = duplicate Sonnet spend, multi-second stall, and possibly a *different* question than the one the tech was answering.
**Fix:** Persist the pending node (e.g., a `pending_node` JSONB column on `l1_walk_sessions`, or reuse `current_node_id` + stored payload) and replay it when `node_id is None` and a pending node exists. Note: if adding columns, this is the moment to also add `category` and `problem_text` columns and delete the meta-entry convention (Root cause B) — `/next-node` currently re-fetches the internal ticket and re-scans walked_path on every step (`l1.py:302-310`) just to recover these immutable values.
### 9. Hidden meta entry: junk proposals + depth cap off-by-one
- Junk proposal: `l1_session_service.py:270``if helpful and session.session_kind == "ai_build" and session.walked_path:` — a meta-only walked_path (seeded at intake, `l1.py:132-134`) is truthy. `normalize_walked_path` (`ai_tree_builder.py:131-137`) strips meta → empty → returns the `"Empty walk — needs authoring."` stub, which "passes the proposal approval guard" per its own docstring → a `status="pending"`, `validated_by_outcome=True` junk proposal reaches the review queue when a tech resolves immediately after intake.
- Depth cap: `l1_session_service.py:172-173` passes the **raw** walked_path; `ai_tree_builder.py:82-83,96-98``len(walked_path) >= MAX_DEPTH` (12) counts the meta entry → cap fires after 11 real steps. `_strip_meta` is applied only downstream.
**Fix (symptom-level):** strip meta before both the truthiness guard and `escalate_if_depth_exceeded`. **Fix (root):** real `category` column, delete the meta convention (see Finding 8 note). Root fix preferred per project principle (correct architecture over minimal diff).
### 10. Escalation notification silently dropped when recipient query is empty
- `notification_service.py:180` changed `if target_user_ids:``if target_user_ids is not None:` (intentional, documented at lines 176-178).
- `l1_session_service.py:371-381``escalate()` passes its computed `target_ids` unconditionally; if all owners/admins/engineers are inactive, `[]` → zero in-app notifications, no log, no fallback. (Existing callers are safe — they all use `[x] if x else None` patterns.)
- Bonus divergence: escalate's hand-rolled query filters only `is_active`, while `handoff_manager.py:323-333` also filters `deleted_at IS NULL` — soft-deleted engineers would be notified.
**Fix:** In `escalate()`: `target_user_ids=target_ids or None` (falls back to default recipients) plus a warning log when empty; add the `deleted_at` filter. Longer-term: give `_resolve_recipients` a roles parameter so callers stop hand-rolling recipient queries.
---
## Cleanups (optional, do alongside adjacent fixes)
- `L1Dashboard.tsx:47-110``handleStart` / `useSuggestedFlow` / `buildNew` are three near-identical intake calls; collapse to one `runIntake(opts)` switching on `response.outcome` (this also prevents Finding-4-class drift).
- `backend/app/schemas/l1.py``IntakeRequest.flow_id` is dead unless Finding 4 revives it; `NextNodeRequest.acknowledged` is sent by the frontend but never read by the backend (advance infers ack from `answer is None`) — wire it or drop it. `IntakeResponse` lost its per-outcome guarantees (all fields Optional, `ticket_kind` no longer `Literal["psa","internal"]`); add a `model_validator(mode="after")` requiring `session_id`/`ticket_id` when outcome is matched/build, and add a `session_id` null-guard before `navigate()` in `handleStart` (`L1Dashboard.tsx:58-59` — currently navigates to `/l1/walk/undefined` on a regression).
- `backend/app/services/match_or_build.py:55` — unused positional `ticket_ref` param (only caller passes `""`); delete it. Also note `classify()` is a second bespoke LLM intake classifier alongside `flowpilot_engine._classify_intake`; `l1.py` passes `problem_domain=None` to matching, losing the domain signal the existing classifier provides — consider unifying in Phase 2B.
- `backend/app/services/l1_category_service.py:17` + `models/account.py:75-81` — DEFAULT_L1_CATEGORIES duplicated as a hand-escaped JSON `server_default`; derive one from the other (migration copy stays frozen).
- `frontend/src/pages/account/L1CategoriesPage.tsx` — local `prettify()` duplicates `humanizeFeatureKey` (`UpgradePrompt.tsx:62`); page skips shared `PageHeader`/`Spinner` used by sibling settings pages.
- Missing index for `GET /l1/escalations` (`l1.py:338`): consider `CREATE INDEX ... ON l1_walk_sessions (account_id, last_step_at DESC) WHERE status = 'escalated'`.
- `backend/tests/test_l1_session_service.py` — the `escalation_reason_category == "no_kb_content"` assertion was deleted from `test_escalate_without_walk_writes_audit_log`, weakening audit coverage; restore it.
- Per-step `walked_path` rewrite is O(n²) cumulative bytes (`session.walked_path = [*session.walked_path, entry]`); bounded by MAX_DEPTH=12 so fine today — note for Phase 2B if depth grows.
---
## Suggested execution order
1. Finding 1 (node ids) — unblocks everything; add the contract test.
2. Finding 6 (FK/constraint) — new migration; do early so it ships in the same release.
3. Findings 2 + 3 together (mount section, fix field names/meta filter, ProposalDetail L1 block + null-guard the /pilot link).
4. Finding 4 (intake honors flow_id; suggest card passes it).
5. Finding 5 (decide adhoc: restore option (a) recommended, or fix copy + DECISIONS.md).
6. Finding 7 (align all three permission layers).
7. Findings 8 + 9 via the root fix: add `category`/`problem_text` (+ optionally `pending_node`) columns, delete the meta-entry convention, strip-meta fixes become moot.
8. Finding 10 (one-line guard + deleted_at filter).
9. Cleanups opportunistically alongside the file they touch.
After fixes: run the 11 Phase 2A backend test files together (authoritative gate per HANDOFF — do NOT trust a full local serial `pytest tests/`; use `--override-ini="addopts="`), frontend `tsc -b` + lint + build, and migration downgrade/upgrade roundtrip. Update `.ai/HANDOFF.md` to correct the Task 16/17 record.

View File

@@ -71,14 +71,19 @@ test.describe('L1 Workspace', () => {
page.getByRole('heading', { name: /Good (morning|afternoon|evening)/i }), page.getByRole('heading', { name: /Good (morning|afternoon|evening)/i }),
).toBeVisible() ).toBeVisible()
// Fill in problem statement textarea // Fill in problem statement textarea. The problem must NOT keyword-match
// any DEFAULT_L1_CATEGORIES token (Phase 2A routes in-category problems to
// an AI-build walk, not ad-hoc) — a custom LOB app is out of scope.
const problemTextarea = page.getByPlaceholder("What's the user calling about?") const problemTextarea = page.getByPlaceholder("What's the user calling about?")
await expect(problemTextarea).toBeVisible() await expect(problemTextarea).toBeVisible()
await problemTextarea.fill('Customer says Outlook is broken after the latest update') await problemTextarea.fill('Custom LOB billing app crashes on launch for one user')
// Click "Start walk →" button // Click "Start walk →" button
await page.getByRole('button', { name: /Start walk/i }).click() await page.getByRole('button', { name: /Start walk/i }).click()
// Out-of-scope prompt offers the free-form fallback — take it
await page.getByRole('button', { name: /Walk it ad-hoc/i }).click()
// Should navigate to /l1/walk/<uuid> // Should navigate to /l1/walk/<uuid>
await expect(page).toHaveURL(/\/l1\/walk\//, { timeout: 10_000 }) await expect(page).toHaveURL(/\/l1\/walk\//, { timeout: 10_000 })

View File

@@ -1,7 +1,10 @@
import { apiClient } from './client' import { apiClient } from './client'
import type { import type {
IntakeRequest, IntakeRequest,
IntakeResponse, IntakeResult,
L1Categories,
NextNodeRequest,
NextNodeResult,
QueueRow, QueueRow,
WalkSession, WalkSession,
AdhocNote, AdhocNote,
@@ -9,7 +12,23 @@ import type {
export const l1Api = { export const l1Api = {
intake: (body: IntakeRequest) => intake: (body: IntakeRequest) =>
apiClient.post<IntakeResponse>('/l1/intake', body).then(r => r.data), apiClient.post<IntakeResult>('/l1/intake', body).then(r => r.data),
nextNode: (sessionId: string, body: NextNodeRequest) =>
apiClient
.post<NextNodeResult>(`/l1/sessions/${sessionId}/next-node`, body)
.then(r => r.data),
escalations: () =>
apiClient.get<WalkSession[]>('/l1/escalations').then(r => r.data),
getCategories: () =>
apiClient.get<L1Categories>('/accounts/me/l1-categories').then(r => r.data),
setCategories: (enabled: string[]) =>
apiClient
.patch<L1Categories>('/accounts/me/l1-categories', { enabled })
.then(r => r.data),
queue: (statusFilter?: string) => queue: (statusFilter?: string) =>
apiClient.get<QueueRow[]>('/l1/queue', { apiClient.get<QueueRow[]>('/l1/queue', {

View File

@@ -88,7 +88,11 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-5"> <div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Source session link */} {/* Source — exactly one of a FlowPilot session XOR an L1 walk is set
(DB CHECK). Never link to /pilot for an L1-sourced proposal:
source_session_id is NULL there, so the old unconditional link
rendered a broken /pilot/null. */}
{proposal.source_session_id ? (
<div className="card-flat p-4"> <div className="card-flat p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source Session</h4> <h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source Session</h4>
<Link <Link
@@ -100,6 +104,19 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
View session that generated this proposal View session that generated this proposal
</Link> </Link>
</div> </div>
) : proposal.l1_session_id ? (
<div className="card-flat p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted mb-2">Source L1 AI walkthrough</h4>
<p className="text-sm text-muted-foreground">
Captured from an L1 technician's AI-guided walk and validated by a
successful resolution. The proposed flow is the path that resolved the ticket.
</p>
<p className="mt-2 flex items-center gap-1.5 text-xs text-text-muted">
<Hash size={11} />
<span className="font-mono">L1 session {proposal.l1_session_id.slice(0, 8)}</span>
</p>
</div>
) : null}
{/* Proposed diff (for enhancements) */} {/* Proposed diff (for enhancements) */}
{proposal.proposed_diff && (() => { {proposal.proposed_diff && (() => {

View File

@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { l1Api } from '@/api/l1'
import { timeAgo } from '@/lib/timeAgo'
import type { WalkSession } from '@/types/l1'
/**
* Engineer-visible list of escalated L1 sessions (the Phase 2A handoff queue).
* Backed by GET /l1/escalations (engineer-or-above). Pollable, dependency-free —
* each row expands to show the walked path summary. Renders nothing if empty.
*/
export function L1EscalationsSection() {
const [rows, setRows] = useState<WalkSession[]>([])
const [loaded, setLoaded] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
useEffect(() => {
l1Api
.escalations()
.then(setRows)
.catch(() => setRows([]))
.finally(() => setLoaded(true))
}, [])
if (!loaded || rows.length === 0) return null
return (
<section className="space-y-3">
<div>
<h2 className="font-heading text-lg font-bold text-heading">L1 escalations</h2>
<p className="text-sm text-text-muted">
Tickets an L1 tech escalated mid-walk pick one up to continue.
</p>
</div>
<div className="rounded-lg border border-default bg-card overflow-hidden">
{rows.map((s) => {
const isOpen = expanded === s.id
return (
<div key={s.id} className="border-b border-default last:border-b-0">
<button
type="button"
onClick={() => setExpanded(isOpen ? null : s.id)}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-elevated transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-xs text-text-muted">#{s.id.slice(0, 8)}</span>
<span className="text-sm text-text-primary truncate">
{s.problem_text
? s.problem_text
: `${s.walked_path.length} step${s.walked_path.length === 1 ? '' : 's'} walked`}
</span>
</div>
<span className="text-xs text-text-muted whitespace-nowrap">
{timeAgo(s.last_step_at)}
</span>
</button>
{isOpen && (
<div className="px-4 pb-3 space-y-1.5">
{s.walked_path.length === 0 ? (
<p className="text-xs text-text-muted">No steps recorded.</p>
) : (
<ol className="space-y-1.5 text-sm">
{s.walked_path.map((step, i) => (
<li key={i} className="flex flex-col">
<span className="text-text-muted text-xs">{step.question ?? step.text}</span>
{step.answer && (
<span className="font-medium text-text-primary"> {step.answer_label ?? step.answer}</span>
)}
</li>
))}
</ol>
)}
</div>
)}
</div>
)
})}
</div>
</section>
)
}

View File

@@ -1,8 +1,8 @@
import { useState } from 'react' import { useState, useEffect, useCallback } from 'react'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { l1Api } from '@/api/l1' import { l1Api } from '@/api/l1'
import type { WalkSession } from '@/types/l1' import type { TreeNode, WalkSession } from '@/types/l1'
import { EscalateModal, ResolveModal } from '@/components/l1/WalkModals' import { EscalateModal, ResolveModal } from '@/components/l1/WalkModals'
interface Props { interface Props {
@@ -16,6 +16,59 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
const [showEscalate, setShowEscalate] = useState(false) const [showEscalate, setShowEscalate] = useState(false)
const [note, setNote] = useState('') const [note, setNote] = useState('')
// Phase 2A: ai_build sessions are walked node-by-node against /next-node
// (real AI-generated decision tree), not the synthetic stepping below.
const isAiBuild = session.session_kind === 'ai_build'
const [node, setNode] = useState<TreeNode | null>(null)
const [nodeLoading, setNodeLoading] = useState(false)
const [nodeError, setNodeError] = useState<string | null>(null)
useEffect(() => {
if (!isAiBuild || session.status !== 'active') return
let cancelled = false
setNodeLoading(true)
l1Api
.nextNode(session.id, {})
.then((r) => {
if (!cancelled) setNode(r.node)
})
.catch(() => {
if (!cancelled) setNodeError('Could not generate the next step.')
})
.finally(() => {
if (!cancelled) setNodeLoading(false)
})
return () => {
cancelled = true
}
}, [isAiBuild, session.id, session.status])
const advanceNode = useCallback(
async (body: { answer?: 'yes' | 'no' }) => {
if (!node) return
setNodeLoading(true)
setNodeError(null)
try {
const r = await l1Api.nextNode(session.id, {
node_id: node.id,
node_text: node.text,
...body,
})
setNode(r.node)
} catch {
setNodeError('Could not generate the next step.')
} finally {
setNodeLoading(false)
}
},
[node, session.id],
)
const isTerminalNode =
node?.node_type === 'resolved' ||
node?.node_type === 'escalate' ||
node?.node_type === 'needs_review'
// Phase 1: we don't have the live flow-tree fetch wired up here yet // Phase 1: we don't have the live flow-tree fetch wired up here yet
// (the tree-navigation pages have their own loader). The walker shows the // (the tree-navigation pages have their own loader). The walker shows the
// walked-path side panel, advance buttons stubbed for now — Phase 2 wires // walked-path side panel, advance buttons stubbed for now — Phase 2 wires
@@ -55,7 +108,7 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
<Link to="/l1" className="flex items-center gap-2 text-muted-foreground hover:text-heading transition-colors"> <Link to="/l1" className="flex items-center gap-2 text-muted-foreground hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
<span className="font-mono text-xs">#{session.id.slice(0, 8)}</span> <span className="font-mono text-xs">#{session.id.slice(0, 8)}</span>
{session.session_kind === 'proposal' && ( {(session.session_kind === 'proposal' || session.session_kind === 'ai_build') && (
<span className="ml-2 text-xs bg-accent/10 text-accent px-2 py-0.5 rounded">AI-built</span> <span className="ml-2 text-xs bg-accent/10 text-accent px-2 py-0.5 rounded">AI-built</span>
)} )}
</Link> </Link>
@@ -80,6 +133,13 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
{/* Two-pane body */} {/* Two-pane body */}
<div className="flex-1 flex min-h-0"> <div className="flex-1 flex min-h-0">
<main className="flex-1 p-6 overflow-y-auto min-h-0"> <main className="flex-1 p-6 overflow-y-auto min-h-0">
{isAiBuild && (
<div className="mb-4 max-w-2xl rounded-md border border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning">
These are high-confidence troubleshooting steps, but they come from
outside your organizations knowledge base review them before acting.
When in doubt, escalate early.
</div>
)}
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground mb-2"> <p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground mb-2">
Step {session.walked_path.length + 1} Step {session.walked_path.length + 1}
</p> </p>
@@ -92,6 +152,66 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
Back to workspace Back to workspace
</button> </button>
</div> </div>
) : isAiBuild ? (
<div className="rounded-lg border border-default bg-card p-6 max-w-2xl space-y-4">
{nodeLoading && (
<p className="text-sm text-muted-foreground">Thinking through the next step</p>
)}
{nodeError && <p className="text-sm text-danger">{nodeError}</p>}
{!nodeLoading && node?.node_type === 'question' && (
<>
<p className="text-lg">{node.text}</p>
<div className="flex gap-3">
<button
onClick={() => advanceNode({ answer: 'yes' })}
className="flex-1 rounded-md bg-accent text-white py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
>
{node.yes_label ?? 'Yes'}
</button>
<button
onClick={() => advanceNode({ answer: 'no' })}
className="flex-1 rounded-md border border-default py-3 text-base font-medium hover:bg-elevated min-h-[44px] transition-colors"
>
{node.no_label ?? 'No'}
</button>
</div>
</>
)}
{!nodeLoading && node?.node_type === 'instruction' && (
<>
<p className="text-lg">{node.text}</p>
<button
onClick={() => advanceNode({})}
className="rounded-md bg-accent text-white px-5 py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
>
Done next step
</button>
</>
)}
{!nodeLoading && isTerminalNode && node && (
<>
<p className="text-lg">{node.text}</p>
{node.node_type === 'resolved' ? (
<button
onClick={() => setShowResolve(true)}
className="rounded-md bg-accent text-white px-5 py-3 text-base font-medium hover:bg-accent/90 min-h-[44px] transition-colors"
>
Mark resolved
</button>
) : (
<button
onClick={() => setShowEscalate(true)}
className="rounded-md bg-warning text-white px-5 py-3 text-base font-medium hover:bg-warning/90 min-h-[44px] transition-colors"
>
Escalate to engineering
</button>
)}
</>
)}
</div>
) : ( ) : (
<div className="rounded-lg border border-default bg-card p-6 max-w-2xl"> <div className="rounded-lg border border-default bg-card p-6 max-w-2xl">
<p className="text-lg mb-6">Continue the walk:</p> <p className="text-lg mb-6">Continue the walk:</p>
@@ -131,8 +251,8 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
<ol className="space-y-3 text-sm"> <ol className="space-y-3 text-sm">
{session.walked_path.map((step, i) => ( {session.walked_path.map((step, i) => (
<li key={i} className="flex flex-col"> <li key={i} className="flex flex-col">
<span className="text-muted-foreground text-xs">{step.question}</span> <span className="text-muted-foreground text-xs">{step.question ?? step.text}</span>
<span className="font-medium"> {step.answer}</span> {step.answer && <span className="font-medium"> {step.answer_label ?? step.answer}</span>}
{step.l1_note && <span className="text-muted-foreground text-xs italic mt-0.5">{step.l1_note}</span>} {step.l1_note && <span className="text-muted-foreground text-xs italic mt-0.5">{step.l1_note}</span>}
</li> </li>
))} ))}

View File

@@ -5,13 +5,18 @@ import { Spinner } from '@/components/common/Spinner'
interface ProtectedRouteProps { interface ProtectedRouteProps {
requiredRole?: EffectiveRole requiredRole?: EffectiveRole
// Gate on account-management capability (owner OR account-admin OR super_admin),
// mirroring backend require_account_owner_or_admin. Use instead of
// requiredRole="owner" when account admins must also pass — the role hierarchy
// has no 'admin' rung, so requiredRole alone wrongly bounces admins.
requireAccountManager?: boolean
children: React.ReactNode children: React.ReactNode
} }
export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) { export function ProtectedRoute({ requiredRole, requireAccountManager, children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuthStore() const { isAuthenticated, isLoading, user } = useAuthStore()
const location = useLocation() const location = useLocation()
const { effectiveRole } = usePermissions() const { effectiveRole, canManageAccount } = usePermissions()
if (isLoading) { if (isLoading) {
return ( return (
@@ -48,6 +53,10 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
} }
} }
if (requireAccountManager && !canManageAccount) {
return <Navigate to="/trees" replace />
}
if (requiredRole) { if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = { const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 5, super_admin: 5,

View File

@@ -88,7 +88,13 @@ export function usePermissions() {
// Management permissions // Management permissions
canManageCategories: hasMinimumRole(user, 'owner'), canManageCategories: hasMinimumRole(user, 'owner'),
canManageGlobalCategories: effectiveRole === 'super_admin', canManageGlobalCategories: effectiveRole === 'super_admin',
canManageAccount: effectiveRole === 'super_admin' || effectiveRole === 'owner', // Mirrors backend User.can_manage_account (super_admin OR owner OR admin).
// account_role 'admin' isn't in the effectiveRole hierarchy, so check it
// directly — otherwise account admins map to 'viewer' and are wrongly excluded.
canManageAccount:
effectiveRole === 'super_admin' ||
effectiveRole === 'owner' ||
user?.account_role === 'admin',
canManageScriptTemplate: (template: { created_by: string | null; team_id?: string | null }) => { canManageScriptTemplate: (template: { created_by: string | null; team_id?: string | null }) => {
if (!user) return false if (!user) return false

View File

@@ -18,6 +18,7 @@ import {
RefreshCw, RefreshCw,
Server, Server,
Shield, Shield,
Wand2,
UserCog, UserCog,
X, X,
} from 'lucide-react' } from 'lucide-react'
@@ -662,6 +663,12 @@ export function AccountSettingsPage() {
title="Team categories" title="Team categories"
description="Shared flow categories for your workspace" description="Shared flow categories for your workspace"
/> />
<SettingsRow
to="/account/l1-categories"
icon={<Wand2 className="h-4 w-4" />}
title="L1 AI build categories"
description="Which problem types the L1 assistant may build trees for"
/>
<SettingsRow <SettingsRow
to="/account/target-lists" to="/account/target-lists"
icon={<Server className="h-4 w-4" />} icon={<Server className="h-4 w-4" />}

View File

@@ -1,13 +1,14 @@
import { useState } from 'react' import { useState } from 'react'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { EscalationQueue, EscalationMetricCard } from '@/components/flowpilot' import { EscalationQueue, EscalationMetricCard } from '@/components/flowpilot'
import { L1EscalationsSection } from '@/components/l1/L1EscalationsSection'
export default function EscalationQueuePage() { export default function EscalationQueuePage() {
const [count, setCount] = useState<number | null>(null) const [count, setCount] = useState<number | null>(null)
return ( return (
<div className="mx-auto max-w-4xl p-6"> <div className="mx-auto max-w-4xl p-6 space-y-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-warning-dim"> <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-warning-dim">
<AlertTriangle size={16} className="text-warning" /> <AlertTriangle size={16} className="text-warning" />
</span> </span>
@@ -24,6 +25,10 @@ export default function EscalationQueuePage() {
<EscalationMetricCard period="30d" /> <EscalationMetricCard period="30d" />
<EscalationQueue onCountChange={setCount} /> <EscalationQueue onCountChange={setCount} />
{/* L1 AI-build handoffs (GET /l1/escalations). Renders nothing when empty,
so engineers without L1 escalations see no change. */}
<L1EscalationsSection />
</div> </div>
) )
} }

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { PageMeta } from '@/components/common/PageMeta'
import { l1Api } from '@/api/l1'
import { toast } from '@/lib/toast'
import type { L1Categories } from '@/types/l1'
const prettify = (key: string) =>
key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
export default function L1CategoriesPage() {
const [data, setData] = useState<L1Categories | null>(null)
const [saving, setSaving] = useState<string | null>(null)
useEffect(() => {
l1Api
.getCategories()
.then(setData)
.catch(() => toast.error('Failed to load L1 categories.'))
}, [])
const toggle = async (cat: string) => {
if (!data) return
const next = data.enabled.includes(cat)
? data.enabled.filter((c) => c !== cat)
: [...data.enabled, cat]
setSaving(cat)
try {
const updated = await l1Api.setCategories(next)
setData({ ...data, enabled: updated.enabled })
toast.success('L1 categories updated.')
} catch {
toast.error('Could not update categories.')
} finally {
setSaving(null)
}
}
if (!data) {
return (
<div className="max-w-2xl">
<PageMeta title="L1 AI Build Categories" />
<p className="text-sm text-muted-foreground">Loading</p>
</div>
)
}
return (
<div className="max-w-2xl space-y-6">
<PageMeta title="L1 AI Build Categories" />
<div>
<h1 className="font-heading text-2xl font-bold text-heading">
L1 AI build categories
</h1>
<p className="mt-2 text-sm text-muted-foreground">
When an L1 tech describes a problem with no matching published flow, the
assistant can build a troubleshooting tree on the fly but only for the
categories you enable here. Disabled categories fall back to an ad-hoc walk
or escalation.
</p>
</div>
<div className="space-y-2">
{data.available.map((cat) => {
const checked = data.enabled.includes(cat)
return (
<label
key={cat}
className="flex items-center gap-3 rounded-md border border-default bg-card px-4 py-3 cursor-pointer hover:bg-elevated transition-colors"
>
<input
type="checkbox"
checked={checked}
disabled={saving === cat}
onChange={() => toggle(cat)}
className="h-4 w-4 accent-accent"
/>
<span className="text-sm text-primary">{prettify(cat)}</span>
</label>
)
})}
</div>
<div>
<h2 className="font-heading text-sm font-semibold text-heading mb-2">
Always excluded (safety)
</h2>
<p className="text-xs text-muted-foreground mb-2">
These action classes are never built automatically and cannot be enabled.
</p>
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
{data.hard_floor.map((h) => (
<li key={h}>{prettify(h)}</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { l1Api } from '@/api/l1'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { EmptyStateCard } from '@/components/l1/EmptyStateCard' import { EmptyStateCard } from '@/components/l1/EmptyStateCard'
import { ResumeInProgress } from '@/components/l1/ResumeInProgress' import { ResumeInProgress } from '@/components/l1/ResumeInProgress'
import type { QueueRow } from '@/types/l1' import type { IntakeRequest, NearMiss, QueueRow } from '@/types/l1'
export default function L1Dashboard() { export default function L1Dashboard() {
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
@@ -17,6 +17,8 @@ export default function L1Dashboard() {
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [queue, setQueue] = useState<QueueRow[]>([]) const [queue, setQueue] = useState<QueueRow[]>([])
const [isEmpty, setIsEmpty] = useState(false) const [isEmpty, setIsEmpty] = useState(false)
const [suggestion, setSuggestion] = useState<NearMiss | null>(null)
const [outOfScope, setOutOfScope] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
l1Api.queue('open').then(setQueue).catch(() => setQueue([])) l1Api.queue('open').then(setQueue).catch(() => setQueue([]))
@@ -37,16 +39,48 @@ export default function L1Dashboard() {
} }
}, [queue]) }, [queue])
const handleStart = async () => { const resetPrompts = () => {
setSuggestion(null)
setOutOfScope(null)
}
// Single intake entry point — `opts` selects the variant:
// {} → normal match-or-build
// { flow_id } → "Use this flow" (bypass matcher, walk that flow)
// { force_build: true } → "Build new" (skip match, still category-gated)
// { adhoc: true } → out-of-scope "Walk it ad-hoc"
// Collapsing the old three near-identical handlers removes the drift that let
// "Use this flow" silently re-suggest forever (it never passed the flow_id).
const runIntake = async (opts: Partial<IntakeRequest> = {}) => {
if (!problem.trim()) return if (!problem.trim()) return
setSubmitting(true) setSubmitting(true)
resetPrompts()
try { try {
const response = await l1Api.intake({ const response = await l1Api.intake({
problem_statement: problem.trim(), problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined, customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined, customer_contact: customerContact.trim() || undefined,
...opts,
}) })
switch (response.outcome) {
case 'matched':
case 'build':
case 'adhoc':
if (response.session_id) {
navigate(`/l1/walk/${response.session_id}`) navigate(`/l1/walk/${response.session_id}`)
} else {
// Backend guarantees session_id on these outcomes; guard so a
// regression never navigates to /l1/walk/undefined.
toast.error('Walk started but no session was returned. Try again.')
}
break
case 'suggest':
setSuggestion(response.near_miss ?? null)
break
case 'out_of_scope':
setOutOfScope(response.category ?? 'unknown')
break
}
} catch (err) { } catch (err) {
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
const msg = const msg =
@@ -57,6 +91,36 @@ export default function L1Dashboard() {
} }
} }
const handleStart = () => runIntake()
// "Use this flow" — pass the near-miss flow_id so intake walks it directly
// (the matcher can't reliably re-derive the same flow from the same text).
const useSuggestedFlow = () => runIntake({ flow_id: suggestion?.flow_id })
// "Build new" — skip the match pass (force_build); still gated by categories.
const buildNew = () => runIntake({ force_build: true })
// "Walk it ad-hoc" — out-of-scope fallback: a free-form walk (no AI tree).
const walkAdhoc = () => runIntake({ adhoc: true })
// out-of-scope fallback: escalate straight to engineering (no walk).
const escalateOutOfScope = async () => {
if (!problem.trim()) return
setSubmitting(true)
try {
const session = await l1Api.escalateWithoutWalk({
problem_statement: problem.trim(),
customer_name: customerName.trim() || undefined,
customer_contact: customerContact.trim() || undefined,
reason_category: 'out_of_scope',
reason: 'Problem is outside the enabled L1 AI-build categories.',
})
toast.success('Escalated to engineering.')
navigate(`/l1/walk/${session.id}`)
} catch {
toast.error('Could not escalate. Try again.')
} finally {
setSubmitting(false)
}
}
const now = new Date() const now = new Date()
const greeting = const greeting =
now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening' now.getHours() < 12 ? 'morning' : now.getHours() < 18 ? 'afternoon' : 'evening'
@@ -160,6 +224,72 @@ export default function L1Dashboard() {
</section> </section>
)} )}
{/* Suggest: near-miss flow found */}
{suggestion && (
<div className="space-y-3 rounded-lg border border-default bg-card p-4">
<p className="text-sm text-primary">
Found a similar flow: <strong>{suggestion.flow_name}</strong>. Use it, or
build a new troubleshooting tree for this problem?
</p>
<div className="flex gap-2">
<button
type="button"
onClick={useSuggestedFlow}
disabled={submitting}
className="rounded-md bg-accent text-white px-4 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
Use this flow
</button>
<button
type="button"
onClick={buildNew}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Build new
</button>
</div>
</div>
)}
{/* Out of scope: category disabled/unknown */}
{outOfScope && (
<div className="space-y-3 rounded-lg border border-default bg-card p-4">
<p className="text-sm text-primary">
This problem isnt in your accounts enabled L1 categories
{outOfScope !== 'unknown' ? ` (${outOfScope.replace(/_/g, ' ')})` : ''}, so
theres no AI-built walk for it. You can still walk it ad-hoc (free-form
notes, no AI tree), or escalate it to engineering.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={walkAdhoc}
disabled={submitting}
className="rounded-md bg-accent text-white px-4 py-2 text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
>
Walk it ad-hoc
</button>
<button
type="button"
onClick={escalateOutOfScope}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Escalate to engineering
</button>
<button
type="button"
onClick={resetPrompts}
disabled={submitting}
className="rounded-md border border-default px-4 py-2 text-sm hover:bg-elevated transition-colors disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
)}
{/* Resume in progress */} {/* Resume in progress */}
<ResumeInProgress /> <ResumeInProgress />
</div> </div>

View File

@@ -114,6 +114,7 @@ const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/Integration
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage')) const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage')) const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage')) const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage'))
const L1CategoriesPage = lazyWithRetry(() => import('@/pages/account/L1CategoriesPage'))
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */ /** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
function page(Component: React.LazyExoticComponent<React.ComponentType>) { function page(Component: React.LazyExoticComponent<React.ComponentType>) {
@@ -363,6 +364,14 @@ export const router = sentryCreateBrowserRouter([
</ProtectedRoute> </ProtectedRoute>
), ),
}, },
{
path: 'l1-categories',
element: (
<ProtectedRoute requireAccountManager>
{page(L1CategoriesPage)}
</ProtectedRoute>
),
},
{ {
path: 'chat-retention', path: 'chat-retention',
element: ( element: (

View File

@@ -10,7 +10,10 @@ export interface FlowProposalSummary {
supporting_session_count: number supporting_session_count: number
status: 'pending' | 'approved' | 'modified' | 'rejected' | 'dismissed' | 'auto_reinforced' status: 'pending' | 'approved' | 'modified' | 'rejected' | 'dismissed' | 'auto_reinforced'
target_flow_id: string | null target_flow_id: string | null
source_session_id: string // Exactly one source is set: source_session_id (FlowPilot ai_session) XOR
// l1_session_id (L1 ai_build walk). Both nullable on the backend (Phase 2A).
source_session_id: string | null
l1_session_id: string | null
created_at: string created_at: string
} }

View File

@@ -1,11 +1,19 @@
export type SessionKind = 'flow' | 'proposal' | 'adhoc' export type SessionKind = 'flow' | 'proposal' | 'adhoc' | 'ai_build'
export type SessionStatus = 'active' | 'resolved' | 'escalated' | 'abandoned' export type SessionStatus = 'active' | 'resolved' | 'escalated' | 'abandoned'
export type TicketKind = 'psa' | 'internal' export type TicketKind = 'psa' | 'internal'
export interface WalkStep { export interface WalkStep {
node_id: string // Two shapes coexist (segregated by session_kind): legacy flow/adhoc steps use
question: string // node_id + question; ai_build steps use id + node_type + text. Render with
answer: string // `step.question ?? step.text`.
node_id?: string
id?: string
node_type?: string
question?: string
text?: string
answer: string | null
/** Button text the tech clicked (ai_build); falls back to `answer`. */
answer_label?: string
l1_note: string | null l1_note: string | null
} }
@@ -17,6 +25,8 @@ export interface AdhocNote {
export interface WalkSession { export interface WalkSession {
id: string id: string
session_kind: SessionKind session_kind: SessionKind
category: string | null
problem_text: string | null
flow_id: string | null flow_id: string | null
flow_proposal_id: string | null flow_proposal_id: string | null
current_node_id: string | null current_node_id: string | null
@@ -42,11 +52,56 @@ export interface IntakeRequest {
customer_name?: string customer_name?: string
customer_contact?: string customer_contact?: string
flow_id?: string flow_id?: string
adhoc?: boolean
force_build?: boolean
} }
export interface IntakeResponse { export type IntakeOutcome = 'matched' | 'suggest' | 'out_of_scope' | 'build' | 'adhoc'
session_id: string
session_kind: SessionKind export interface NearMiss {
ticket_id: string flow_id: string
ticket_kind: TicketKind flow_name: string
score: number
}
/** Phase 2A intake response — `outcome` drives the frontend dispatch.
* Session fields are present only for `matched` / `build`. */
export interface IntakeResult {
outcome: IntakeOutcome
session_id?: string
session_kind?: SessionKind
ticket_id?: string
ticket_kind?: TicketKind
flow_id?: string // for 'matched'
near_miss?: NearMiss // for 'suggest'
category?: string // for 'out_of_scope'
}
/** A single node of an AI-built decision tree, returned by /next-node.
* Question nodes carry the literal button texts (yes_label/no_label) so the
* choices always match the question ("Microsoft account" / "Local account",
* not a mismatched Yes/No). The backend defaults them to Yes/No. */
export type TreeNode =
| { node_type: 'question'; id: string; text: string; yes_label?: string; no_label?: string }
| { node_type: 'instruction'; id: string; text: string }
| { node_type: 'resolved'; id: string; text: string }
| { node_type: 'escalate'; id: string; reason_category?: string; text: string }
| { node_type: 'needs_review'; id: string; text: string }
export interface NextNodeRequest {
node_id?: string
node_text?: string // rendered text of the node being answered
answer?: 'yes' | 'no' // omit to acknowledge an instruction node
note?: string
}
export interface NextNodeResult {
node: TreeNode
session_status: string
}
export interface L1Categories {
enabled: string[]
available: string[]
hard_floor: string[]
} }

View File

@@ -9,7 +9,7 @@ export interface User {
is_active: boolean is_active: boolean
must_change_password: boolean must_change_password: boolean
account_id: string | null account_id: string | null
account_role: 'owner' | 'engineer' | 'l1_tech' | 'viewer' | null account_role: 'owner' | 'admin' | 'engineer' | 'l1_tech' | 'viewer' | null
can_cover_l1: boolean can_cover_l1: boolean
team_id: string | null team_id: string | null
created_at: string created_at: string