Compare commits
10 Commits
03e87488b0
...
8ce6bc80fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce6bc80fa | |||
| 1b7aedb204 | |||
| 503b243ed4 | |||
| 267e748647 | |||
| 076a9ec98d | |||
| c547d2f834 | |||
| ad9c4c8cd6 | |||
| 3e23a837d4 | |||
| f483196e91 | |||
| df7150fc29 |
148
.ai/HANDOFF.md
148
.ai/HANDOFF.md
@@ -9,72 +9,98 @@
|
|||||||
subagent-driven-development on branch `feat/l1-ai-tree-builder-phase-2a`
|
subagent-driven-development on branch `feat/l1-ai-tree-builder-phase-2a`
|
||||||
(branched from `main` @ `87236b5`; **not pushed**, `main` untouched).
|
(branched from `main` @ `87236b5`; **not pushed**, `main` untouched).
|
||||||
|
|
||||||
## ⚠️ Tooling note (read first)
|
## ⚠️ Tooling note (read first — why this session stopped at Task 16)
|
||||||
This session repeatedly hit an unreliable **Bash output channel** — intermittently
|
The harness's **Bash output channel became intermittently unreliable** — returning
|
||||||
returns stale/cached output (e.g. a unique `echo` probe returned a prior `wc`
|
stale/cached output (a Bash command that wrote `/tmp/perm.txt` instead returned a
|
||||||
result) or fabricated success. The **Read/Write/Edit channel stayed reliable.**
|
PRIOR command's `/tmp/vc.txt` content; a `cat` returned the wrong commit SHA). The
|
||||||
Two derived lessons:
|
Write/Edit channel stayed reliable; Read mostly reliable but occasionally served a
|
||||||
- Run backend tests as `docker exec resolutionflow_backend pytest <path> --override-ini="addopts=" -q`.
|
stale temp file. Work stopped at Task 16 because wiring a new route/nav requires
|
||||||
Do **NOT** use `-p no:cov` — `pytest.ini` bakes `--cov` into `addopts`, so disabling
|
accurately reading `router.tsx` + `AccountSettingsPage.tsx` then editing them, and
|
||||||
the cov plugin makes `--cov` an unrecognized arg and pytest exits non-zero **before
|
read-then-edit against stale reads is exactly what produced the broken Tasks 14–15
|
||||||
running**, which silently mislabels `&& echo PASS || echo FAIL` chains as failures.
|
earlier this session. **On resume: confirm the shell is reliable first** — write a
|
||||||
- After any Bash result that matters, cross-check against a `Read`/`grep` of the file.
|
unique sentinel to a file and read it back; cross-check any Read against a fresh
|
||||||
If a probe returns stale output, **stop and recover the shell** before committing.
|
`grep`; never commit without a sentinel-wrapped `tsc -b`/pytest verification whose
|
||||||
|
unique sentinel you can see in the same output.
|
||||||
|
|
||||||
## Status: backend complete (Tasks 1–12), frontend not started (Tasks 13–19)
|
Earlier-this-session gotcha that cost ~an hour: pytest `-p no:cov` conflicts with the
|
||||||
|
`--cov` baked into `pytest.ini` addopts → pytest exits before running → `&& echo PASS`
|
||||||
|
chains mislabel. Always use `--override-ini="addopts="`, never `-p no:cov`.
|
||||||
|
|
||||||
**Tasks 1–12 — DONE & committed** (17 commits `16b9abf`…`04b5511`). Last reliable
|
Backend test invocation that works:
|
||||||
full run: **114 passed** across all 11 Phase 2A backend test files
|
`docker exec resolutionflow_backend pytest <path> --override-ini="addopts=" -q`
|
||||||
(test_l1_ai_build_model, test_account_l1_categories_column, test_flow_proposal_l1_source,
|
Do **NOT** use `-p no:cov` — `pytest.ini` bakes `--cov` into `addopts`; disabling the
|
||||||
test_l1_category_service, test_ai_tree_builder, test_match_or_build,
|
cov plugin makes `--cov` unrecognized so pytest exits before running, silently turning
|
||||||
test_l1_session_service, test_l1_endpoints, test_l1_api_ai_build, test_l1_categories_api,
|
`&& echo PASS || echo FAIL` chains into false FAILs (this cost ~an hour of confusion).
|
||||||
test_l1_ai_build_flow). 3 alembic migrations applied; head is `1fd88a68b145`.
|
Frontend gate via file-redirect:
|
||||||
|
`docker exec -w /app resolutionflow_frontend sh -c 'npx tsc -b > /app/_o.txt 2>&1; echo EXIT=$? >> /app/_o.txt'`
|
||||||
|
then Read `frontend/_o.txt` (frontend is bind-mounted at /app).
|
||||||
|
|
||||||
What shipped backend-side:
|
## Status: Tasks 1–15 DONE & committed. Tasks 16–19 remain (all frontend + final).
|
||||||
- Migrations/models: `ai_build` session kind; `accounts.enabled_l1_categories` (10-key
|
|
||||||
default); `FlowProposal.l1_session_id` (+ `source_session_id` nullable + exactly-one
|
|
||||||
CHECK), `FlowProposalSummary` schema made source_session_id optional + l1_session_id added.
|
|
||||||
- Services: `l1_category_service` (defaults + hard floor + get/set), `ai_tree_builder`
|
|
||||||
(constrained node gen, validate, depth cap, `normalize_walked_path`, **skips `meta`
|
|
||||||
entries**), `match_or_build` (match→suggest→build bands; flow_id normalized to str),
|
|
||||||
`l1_session_service.start_ai_build_session` / `advance_ai_build` (records node incl.
|
|
||||||
`node_text`) / flywheel capture in `resolve` / engineer notify in `escalate`.
|
|
||||||
- Notifications: `l1.session.escalated` event + link `/escalations` + body/title templates;
|
|
||||||
`_resolve_recipients` now treats an explicit empty `target_user_ids` as "no recipients".
|
|
||||||
- API: `/l1/intake` dispatches via `match_or_build` (build seeds a hidden
|
|
||||||
`{"node_type":"meta","category":...}` walked_path entry); `POST /l1/sessions/{id}/next-node`;
|
|
||||||
`GET /l1/escalations` (require_engineer_or_admin); `GET|PATCH /accounts/me/l1-categories`;
|
|
||||||
`require_account_owner_or_admin` dep. Config action keys `l1_realtime_build`/`l1_classify`.
|
|
||||||
|
|
||||||
## Resume point — Tasks 13–19 (all frontend + final)
|
**Backend (Tasks 1–12)** — 17 commits `16b9abf`…`04b5511` + handoff `fdac72e`.
|
||||||
|
Last full run: **114 passed** across all 11 Phase 2A backend test files. 3 alembic
|
||||||
|
migrations applied; head `1fd88a68b145`. Shipped: `ai_build` session kind;
|
||||||
|
`accounts.enabled_l1_categories`; `FlowProposal.l1_session_id` (+ nullable
|
||||||
|
source_session_id + exactly-one CHECK + schema made optional); `l1_category_service`;
|
||||||
|
`ai_tree_builder` (constrained gen, validate, depth cap, `normalize_walked_path`,
|
||||||
|
**skips `meta` entries**); `match_or_build` (bands; flow_id→str); session-service
|
||||||
|
`start_ai_build_session`/`advance_ai_build` (stores `node_text`)/flywheel capture in
|
||||||
|
`resolve`/engineer notify in `escalate`; `l1.session.escalated` notification (+ link
|
||||||
|
`/escalations` + `_resolve_recipients` honors explicit empty list); API
|
||||||
|
`/l1/intake` (dispatch; build seeds hidden `{"node_type":"meta","category":...}`
|
||||||
|
walked_path entry), `POST /l1/sessions/{id}/next-node`, `GET /l1/escalations`,
|
||||||
|
`GET|PATCH /accounts/me/l1-categories`, `require_account_owner_or_admin` dep.
|
||||||
|
|
||||||
1. **Task 13 — frontend api/types.** `frontend/src/types/l1.ts` + `frontend/src/api/l1.ts`
|
**Frontend (Tasks 13–15) — committed; whole-project `tsc -b` + eslint clean. VERIFIED HEAD `076a9ec`, tree clean.**
|
||||||
are still **Phase-1 stubs** (api/l1.ts is a rough 36-line stub — read it carefully).
|
- `03e8748` Task 13 — `types/l1.ts` (+ai_build, IntakeOutcome/Result, NearMiss, TreeNode,
|
||||||
Add: `IntakeOutcome`/`IntakeResult` (outcome matched|suggest|out_of_scope|build,
|
NextNodeRequest/Result, L1Categories) + `api/l1.ts` (intake→IntakeResult; nextNode,
|
||||||
optional session fields, near_miss, category), `TreeNode` union, `NextNodeResult`,
|
escalations, getCategories, setCategories). nextNode body carries `node_text`.
|
||||||
`L1Categories`; methods `nextNode`, `getCategories`, `setCategories`, `escalations`;
|
- Tasks 14/15 took THREE commits because the flaky shell caused two broken commits
|
||||||
retype `intake` to `IntakeResult`. **Carry-forward:** `nextNode` body must include
|
(`df7150f`, `f483196` had missing-export/props errors; `ad9c4c8` was committed with
|
||||||
`node_text` (the rendered node text — backend `advance_ai_build` stores it).
|
TSC_EXIT=2 because I batched the commit with its own failing verification). The REAL
|
||||||
2. **Task 14** — `L1Dashboard.tsx` dispatch on `outcome` (matched/build → walker;
|
working fix is **`076a9ec`** — confirmed via single-value commands: committed
|
||||||
suggest → prompt; out_of_scope → adhoc/escalate prompt).
|
`L1WalkTreeVariant.tsx` has `advanceNode` (grep -c = 3), committed `L1Dashboard.tsx`
|
||||||
3. **Task 15** — `L1WalkTreeVariant.tsx` real node rendering via `/next-node` + disclaimer
|
has `useSuggestedFlow` (= 2); and a sentinel-wrapped `npx tsc -b` returned TSC=0,
|
||||||
banner; pass `node.text` as `node_text`; terminal nodes → existing Resolve/Escalate.
|
eslint=0 on the on-disk files before commit. What landed:
|
||||||
4. **Task 16** — new `pages/account/L1CategoriesPage.tsx` + route + nav (owner/admin gated).
|
- `L1Dashboard.tsx`: outcome dispatch on the REAL page (matched/build→walker;
|
||||||
5. **Task 17** — `ProposalDetail.tsx` L1-source block (branch on `l1_session_id`);
|
suggest→use-flow/build-new; out_of_scope→escalate-without-walk). Original
|
||||||
`EscalationQueuePage.tsx` L1-escalations section via `l1Api.escalations()`.
|
PageMeta/greeting/inputs/open-tickets layout preserved.
|
||||||
6. **Task 18** — extend `frontend/e2e/l1-workspace.spec.ts` (network-stubbed); rely on CI
|
- `L1WalkTreeVariant.tsx`: real props `{session,onSessionUpdate,onDone}` +
|
||||||
for the run (chromium can't launch here).
|
ResolveModal/EscalateModal + header + transcript sidebar kept; added ai_build branch
|
||||||
7. **Task 19** — full backend suite + `tsc -b`/`npm run lint`/`npm run build`; migration
|
that walks nodes via /next-node (passes node_text), disclaimer banner (`bg-warning/10`
|
||||||
downgrade/upgrade roundtrip; push branch + open PR to `main` listing deferred items.
|
— NOTE: `*-dim` tokens are NOT `--color-*-dim`; use `/10` opacity), terminal→modals.
|
||||||
|
flow/proposal keep the Phase-1 synthetic path.
|
||||||
|
- `L1WalkPage.tsx` unchanged (already routes ai_build → tree variant).
|
||||||
|
NOT browser-verified (chromium can't launch here).
|
||||||
|
- **SHELL DISCIPLINE for resume:** single-value Bash commands (`grep -c`, `wc -l`,
|
||||||
|
`git rev-parse --short`, `git log -1 --format=%s`) are RELIABLE; multi-line
|
||||||
|
`{ echo; … } > file` blocks get GARBLED/interleaved. NEVER batch a commit with its
|
||||||
|
own verification — verify in a separate step and READ the result before committing.
|
||||||
|
|
||||||
Frontend gate: `docker exec -w /app resolutionflow_frontend npx tsc -b` and
|
## Resume point — Tasks 16–19
|
||||||
`docker exec -w /app resolutionflow_frontend npm run build` (per PROJECT_CONTEXT).
|
|
||||||
|
|
||||||
**Working tree:** uncommitted at handoff time — only this `HANDOFF.md` edit (and possibly
|
16. **`pages/account/L1CategoriesPage.tsx`** (does NOT exist yet) — checkbox list of
|
||||||
`backend/tests/test_l1_api_ai_build.py` if its last lint-clean edit wasn't committed; verify
|
`available` toggling `enabled` via `l1Api.getCategories/setCategories`; read-only
|
||||||
with `git status` once the shell recovers, then commit WIP).
|
hard-floor list. Register lazy route under the `account` children in `router.tsx`
|
||||||
|
(the L1CategoriesPage import is NOT yet there — verify) and add a link card in
|
||||||
|
`AccountSettingsPage.tsx` (AccountLayout has no sidebar nav — see CLAUDE.md
|
||||||
|
"Account sub-page"). Gate visibility to owner/admin via `usePermissions`.
|
||||||
|
17. **`ProposalDetail.tsx`** — branch on `l1_session_id` to show an L1-source block
|
||||||
|
instead of the `/pilot/{source_session_id}` link (add `l1_session_id?: string|null`
|
||||||
|
to its proposal type). **`EscalationQueuePage.tsx`** — add an "L1 escalations"
|
||||||
|
section via `l1Api.escalations()`.
|
||||||
|
18. **`frontend/e2e/l1-workspace.spec.ts`** — network-stubbed AI-build flow; rely on CI
|
||||||
|
to run it (chromium can't launch here).
|
||||||
|
19. **Final:** full backend suite + `tsc -b`/`npm run lint`/`npm run build`; migration
|
||||||
|
downgrade/upgrade roundtrip (head `1fd88a68b145`, down 3); push branch + open PR to
|
||||||
|
`main` listing deferred items (KB grounding/connectors, PSA reassign, escalation
|
||||||
|
package, AI chat handoff, proposal-matching). Then run requesting-code-review +
|
||||||
|
finishing-a-development-branch per the subagent-driven-development skill.
|
||||||
|
|
||||||
## Carry-forward (Phase O — separate, still user-side, gated on EIN)
|
**Working tree:** clean except this HANDOFF.md edit (committing now). Temp `_*.txt`
|
||||||
Phase O self-serve cutover (Stripe live-mode, apex DNS, Railway prod env, flag flip) remains
|
files under `frontend/` were scratch — delete any that remain.
|
||||||
the prior active task — all code blockers closed; blocked on user's EIN. See SESSION_LOG /
|
|
||||||
git history for detail. Not touched this 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 the prior active task; all code blockers closed, blocked on user's EIN. Not
|
||||||
|
touched this session.
|
||||||
|
|||||||
77
frontend/src/components/l1/L1EscalationsSection.tsx
Normal file
77
frontend/src/components/l1/L1EscalationsSection.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { l1Api } from '@/api/l1'
|
||||||
|
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.walked_path.length} step{s.walked_path.length === 1 ? '' : 's'} walked
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted whitespace-nowrap">
|
||||||
|
{new Date(s.last_step_at).toLocaleString()}
|
||||||
|
</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}</span>
|
||||||
|
{step.answer && (
|
||||||
|
<span className="font-medium text-text-primary">→ {step.answer}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'; acknowledged?: boolean }) => {
|
||||||
|
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 organization’s 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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!nodeLoading && node?.node_type === 'instruction' && (
|
||||||
|
<>
|
||||||
|
<p className="text-lg">{node.text}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => advanceNode({ acknowledged: 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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
98
frontend/src/pages/account/L1CategoriesPage.tsx
Normal file
98
frontend/src/pages/account/L1CategoriesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { 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,8 +39,42 @@ export default function L1Dashboard() {
|
|||||||
}
|
}
|
||||||
}, [queue])
|
}, [queue])
|
||||||
|
|
||||||
|
const resetPrompts = () => {
|
||||||
|
setSuggestion(null)
|
||||||
|
setOutOfScope(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
if (!problem.trim()) return
|
if (!problem.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
resetPrompts()
|
||||||
|
try {
|
||||||
|
// Phase 2A: intake dispatches via match_or_build and returns an `outcome`.
|
||||||
|
const response = await l1Api.intake({
|
||||||
|
problem_statement: problem.trim(),
|
||||||
|
customer_name: customerName.trim() || undefined,
|
||||||
|
customer_contact: customerContact.trim() || undefined,
|
||||||
|
})
|
||||||
|
if (response.outcome === 'matched' || response.outcome === 'build') {
|
||||||
|
navigate(`/l1/walk/${response.session_id}`)
|
||||||
|
} else if (response.outcome === 'suggest') {
|
||||||
|
setSuggestion(response.near_miss ?? null)
|
||||||
|
} else if (response.outcome === 'out_of_scope') {
|
||||||
|
setOutOfScope(response.category ?? 'unknown')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||||
|
const msg =
|
||||||
|
typeof detail === 'string' ? detail : 'Failed to start walk. Try again.'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Use this flow" — re-run intake with the same text; it matches again and
|
||||||
|
// returns a `matched` outcome with a started flow session (acceptable Phase 2A).
|
||||||
|
const useSuggestedFlow = async () => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const response = await l1Api.intake({
|
const response = await l1Api.intake({
|
||||||
@@ -46,12 +82,54 @@ export default function L1Dashboard() {
|
|||||||
customer_name: customerName.trim() || undefined,
|
customer_name: customerName.trim() || undefined,
|
||||||
customer_contact: customerContact.trim() || undefined,
|
customer_contact: customerContact.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
if (response.session_id) navigate(`/l1/walk/${response.session_id}`)
|
||||||
|
else resetPrompts()
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not start the matched flow. Try again.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Build new" — skip the match pass (force_build); still gated by enabled categories.
|
||||||
|
const buildNew = async () => {
|
||||||
|
setSubmitting(true)
|
||||||
|
resetPrompts()
|
||||||
|
try {
|
||||||
|
const response = await l1Api.intake({
|
||||||
|
problem_statement: problem.trim(),
|
||||||
|
customer_name: customerName.trim() || undefined,
|
||||||
|
customer_contact: customerContact.trim() || undefined,
|
||||||
|
force_build: true,
|
||||||
|
})
|
||||||
|
if (response.outcome === 'build' && response.session_id) {
|
||||||
navigate(`/l1/walk/${response.session_id}`)
|
navigate(`/l1/walk/${response.session_id}`)
|
||||||
} catch (err) {
|
} else if (response.outcome === 'out_of_scope') {
|
||||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
setOutOfScope(response.category ?? 'unknown')
|
||||||
const msg =
|
}
|
||||||
typeof detail === 'string' ? detail : 'Failed to start walk. Try again.'
|
} catch {
|
||||||
toast.error(msg)
|
toast.error('Failed to start walk. Try again.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -160,6 +238,63 @@ 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 isn’t in your account’s enabled L1 categories
|
||||||
|
{outOfScope !== 'unknown' ? ` (${outOfScope.replace(/_/g, ' ')})` : ''}, so
|
||||||
|
there’s no AI-built walk for it. You can escalate it to engineering.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={escalateOutOfScope}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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 requiredRole="owner">
|
||||||
|
{page(L1CategoriesPage)}
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'chat-retention',
|
path: 'chat-retention',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user