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`
|
||||
(branched from `main` @ `87236b5`; **not pushed**, `main` untouched).
|
||||
|
||||
## ⚠️ Tooling note (read first)
|
||||
This session repeatedly hit an unreliable **Bash output channel** — intermittently
|
||||
returns stale/cached output (e.g. a unique `echo` probe returned a prior `wc`
|
||||
result) or fabricated success. The **Read/Write/Edit channel stayed reliable.**
|
||||
Two derived lessons:
|
||||
- Run backend tests as `docker exec resolutionflow_backend pytest <path> --override-ini="addopts=" -q`.
|
||||
Do **NOT** use `-p no:cov` — `pytest.ini` bakes `--cov` into `addopts`, so disabling
|
||||
the cov plugin makes `--cov` an unrecognized arg and pytest exits non-zero **before
|
||||
running**, which silently mislabels `&& echo PASS || echo FAIL` chains as failures.
|
||||
- After any Bash result that matters, cross-check against a `Read`/`grep` of the file.
|
||||
If a probe returns stale output, **stop and recover the shell** before committing.
|
||||
## ⚠️ Tooling note (read first — why this session stopped at Task 16)
|
||||
The harness's **Bash output channel became intermittently unreliable** — returning
|
||||
stale/cached output (a Bash command that wrote `/tmp/perm.txt` instead returned a
|
||||
PRIOR command's `/tmp/vc.txt` content; a `cat` returned the wrong commit SHA). The
|
||||
Write/Edit channel stayed reliable; Read mostly reliable but occasionally served a
|
||||
stale temp file. Work stopped at Task 16 because wiring a new route/nav requires
|
||||
accurately reading `router.tsx` + `AccountSettingsPage.tsx` then editing them, and
|
||||
read-then-edit against stale reads is exactly what produced the broken Tasks 14–15
|
||||
earlier this session. **On resume: confirm the shell is reliable first** — write a
|
||||
unique sentinel to a file and read it back; cross-check any Read against a fresh
|
||||
`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
|
||||
full run: **114 passed** across all 11 Phase 2A backend test files
|
||||
(test_l1_ai_build_model, test_account_l1_categories_column, test_flow_proposal_l1_source,
|
||||
test_l1_category_service, test_ai_tree_builder, test_match_or_build,
|
||||
test_l1_session_service, test_l1_endpoints, test_l1_api_ai_build, test_l1_categories_api,
|
||||
test_l1_ai_build_flow). 3 alembic migrations applied; head is `1fd88a68b145`.
|
||||
Backend test invocation that works:
|
||||
`docker exec resolutionflow_backend pytest <path> --override-ini="addopts=" -q`
|
||||
Do **NOT** use `-p no:cov` — `pytest.ini` bakes `--cov` into `addopts`; disabling the
|
||||
cov plugin makes `--cov` unrecognized so pytest exits before running, silently turning
|
||||
`&& echo PASS || echo FAIL` chains into false FAILs (this cost ~an hour of confusion).
|
||||
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:
|
||||
- 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`.
|
||||
## Status: Tasks 1–15 DONE & committed. Tasks 16–19 remain (all frontend + final).
|
||||
|
||||
## 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`
|
||||
are still **Phase-1 stubs** (api/l1.ts is a rough 36-line stub — read it carefully).
|
||||
Add: `IntakeOutcome`/`IntakeResult` (outcome matched|suggest|out_of_scope|build,
|
||||
optional session fields, near_miss, category), `TreeNode` union, `NextNodeResult`,
|
||||
`L1Categories`; methods `nextNode`, `getCategories`, `setCategories`, `escalations`;
|
||||
retype `intake` to `IntakeResult`. **Carry-forward:** `nextNode` body must include
|
||||
`node_text` (the rendered node text — backend `advance_ai_build` stores it).
|
||||
2. **Task 14** — `L1Dashboard.tsx` dispatch on `outcome` (matched/build → walker;
|
||||
suggest → prompt; out_of_scope → adhoc/escalate prompt).
|
||||
3. **Task 15** — `L1WalkTreeVariant.tsx` real node rendering via `/next-node` + disclaimer
|
||||
banner; pass `node.text` as `node_text`; terminal nodes → existing Resolve/Escalate.
|
||||
4. **Task 16** — new `pages/account/L1CategoriesPage.tsx` + route + nav (owner/admin gated).
|
||||
5. **Task 17** — `ProposalDetail.tsx` L1-source block (branch on `l1_session_id`);
|
||||
`EscalationQueuePage.tsx` L1-escalations section via `l1Api.escalations()`.
|
||||
6. **Task 18** — extend `frontend/e2e/l1-workspace.spec.ts` (network-stubbed); rely on CI
|
||||
for the run (chromium can't launch here).
|
||||
7. **Task 19** — full backend suite + `tsc -b`/`npm run lint`/`npm run build`; migration
|
||||
downgrade/upgrade roundtrip; push branch + open PR to `main` listing deferred items.
|
||||
**Frontend (Tasks 13–15) — committed; whole-project `tsc -b` + eslint clean. VERIFIED HEAD `076a9ec`, tree clean.**
|
||||
- `03e8748` Task 13 — `types/l1.ts` (+ai_build, IntakeOutcome/Result, NearMiss, TreeNode,
|
||||
NextNodeRequest/Result, L1Categories) + `api/l1.ts` (intake→IntakeResult; nextNode,
|
||||
escalations, getCategories, setCategories). nextNode body carries `node_text`.
|
||||
- Tasks 14/15 took THREE commits because the flaky shell caused two broken commits
|
||||
(`df7150f`, `f483196` had missing-export/props errors; `ad9c4c8` was committed with
|
||||
TSC_EXIT=2 because I batched the commit with its own failing verification). The REAL
|
||||
working fix is **`076a9ec`** — confirmed via single-value commands: committed
|
||||
`L1WalkTreeVariant.tsx` has `advanceNode` (grep -c = 3), committed `L1Dashboard.tsx`
|
||||
has `useSuggestedFlow` (= 2); and a sentinel-wrapped `npx tsc -b` returned TSC=0,
|
||||
eslint=0 on the on-disk files before commit. What landed:
|
||||
- `L1Dashboard.tsx`: outcome dispatch on the REAL page (matched/build→walker;
|
||||
suggest→use-flow/build-new; out_of_scope→escalate-without-walk). Original
|
||||
PageMeta/greeting/inputs/open-tickets layout preserved.
|
||||
- `L1WalkTreeVariant.tsx`: real props `{session,onSessionUpdate,onDone}` +
|
||||
ResolveModal/EscalateModal + header + transcript sidebar kept; added ai_build branch
|
||||
that walks nodes via /next-node (passes node_text), disclaimer banner (`bg-warning/10`
|
||||
— 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
|
||||
`docker exec -w /app resolutionflow_frontend npm run build` (per PROJECT_CONTEXT).
|
||||
## Resume point — Tasks 16–19
|
||||
|
||||
**Working tree:** uncommitted at handoff time — only this `HANDOFF.md` edit (and possibly
|
||||
`backend/tests/test_l1_api_ai_build.py` if its last lint-clean edit wasn't committed; verify
|
||||
with `git status` once the shell recovers, then commit WIP).
|
||||
16. **`pages/account/L1CategoriesPage.tsx`** (does NOT exist yet) — checkbox list of
|
||||
`available` toggling `enabled` via `l1Api.getCategories/setCategories`; read-only
|
||||
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)
|
||||
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. See SESSION_LOG /
|
||||
git history for detail. Not touched this session.
|
||||
**Working tree:** clean except this HANDOFF.md edit (committing now). Temp `_*.txt`
|
||||
files under `frontend/` were scratch — delete any that remain.
|
||||
|
||||
## 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 { Link } from 'react-router-dom'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +16,59 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
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
|
||||
// (the tree-navigation pages have their own loader). The walker shows the
|
||||
// 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">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<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>
|
||||
)}
|
||||
</Link>
|
||||
@@ -80,6 +133,13 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
|
||||
{/* Two-pane body */}
|
||||
<div className="flex-1 flex 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">
|
||||
Step {session.walked_path.length + 1}
|
||||
</p>
|
||||
@@ -92,6 +152,66 @@ export function L1WalkTreeVariant({ session, onSessionUpdate, onDone }: Props) {
|
||||
Back to workspace
|
||||
</button>
|
||||
</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">
|
||||
<p className="text-lg mb-6">Continue the walk:</p>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RefreshCw,
|
||||
Server,
|
||||
Shield,
|
||||
Wand2,
|
||||
UserCog,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -662,6 +663,12 @@ export function AccountSettingsPage() {
|
||||
title="Team categories"
|
||||
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
|
||||
to="/account/target-lists"
|
||||
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 { EmptyStateCard } from '@/components/l1/EmptyStateCard'
|
||||
import { ResumeInProgress } from '@/components/l1/ResumeInProgress'
|
||||
import type { QueueRow } from '@/types/l1'
|
||||
import type { NearMiss, QueueRow } from '@/types/l1'
|
||||
|
||||
export default function L1Dashboard() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
@@ -17,6 +17,8 @@ export default function L1Dashboard() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [queue, setQueue] = useState<QueueRow[]>([])
|
||||
const [isEmpty, setIsEmpty] = useState(false)
|
||||
const [suggestion, setSuggestion] = useState<NearMiss | null>(null)
|
||||
const [outOfScope, setOutOfScope] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
l1Api.queue('open').then(setQueue).catch(() => setQueue([]))
|
||||
@@ -37,8 +39,42 @@ export default function L1Dashboard() {
|
||||
}
|
||||
}, [queue])
|
||||
|
||||
const resetPrompts = () => {
|
||||
setSuggestion(null)
|
||||
setOutOfScope(null)
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
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)
|
||||
try {
|
||||
const response = await l1Api.intake({
|
||||
@@ -46,12 +82,54 @@ export default function L1Dashboard() {
|
||||
customer_name: customerName.trim() || undefined,
|
||||
customer_contact: customerContact.trim() || undefined,
|
||||
})
|
||||
navigate(`/l1/walk/${response.session_id}`)
|
||||
} 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)
|
||||
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}`)
|
||||
} else if (response.outcome === 'out_of_scope') {
|
||||
setOutOfScope(response.category ?? 'unknown')
|
||||
}
|
||||
} catch {
|
||||
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 {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -160,6 +238,63 @@ export default function L1Dashboard() {
|
||||
</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 */}
|
||||
<ResumeInProgress />
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,7 @@ const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/Integration
|
||||
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
|
||||
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
|
||||
const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage'))
|
||||
const L1CategoriesPage = lazyWithRetry(() => import('@/pages/account/L1CategoriesPage'))
|
||||
|
||||
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
|
||||
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||
@@ -363,6 +364,14 @@ export const router = sentryCreateBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'l1-categories',
|
||||
element: (
|
||||
<ProtectedRoute requiredRole="owner">
|
||||
{page(L1CategoriesPage)}
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'chat-retention',
|
||||
element: (
|
||||
|
||||
@@ -10,7 +10,10 @@ export interface FlowProposalSummary {
|
||||
supporting_session_count: number
|
||||
status: 'pending' | 'approved' | 'modified' | 'rejected' | 'dismissed' | 'auto_reinforced'
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user