feat(pilot): Phase 3 — Suggested fix tracking + Resolve preview with state_version cache

Adds the AI-proposed resolution path and the inline preview of the
markdown that will be posted to the customer ticket on Resolve. The
preview is keyed on (session_id, ai_sessions.state_version) so back-to-
back fetches against unchanged state hit an in-process cache instead
of paying for a Sonnet call.

Backend:
- preview_cache: in-process LRU keyed on (kind, session_id, state_version).
  No TTL — state_version is the source of truth. Soft-cap 5000 entries.
- unified_chat_service: [SUGGEST_FIX] parser (last-block-wins, JSON
  payload, confidence clamped 0-100), supersession persistence (sets
  superseded_at on prior active row), atomic state_version bump.
- ResolutionNoteGeneratorService: pulls session, facts, active fix, and
  redacted script_generations into a structured input bundle for Sonnet;
  produces the four-section markdown (Problem / What we confirmed /
  Root cause / Resolution). Sensitive script parameters redacted via
  ScriptTemplateEngine.redact_sensitive driven by the template's
  parameters_schema.
- /api/v1/ai-sessions/{id}/suggested-fixes/active — 200 with the active
  fix or 404.
- /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision — records
  one_off / draft_template / build_template / dismissed; dismiss
  supersedes; bumps state_version. 409 on dismissing an already-
  superseded fix.
- /api/v1/ai-sessions/{id}/resolution-note/preview — generates or returns
  cached markdown; from_cache flag in payload signals cache hit.
- scripts.py POST /generate now bumps state_version on the linked
  ai_session_id when present (third source of preview-cache invalidation
  per Section 5.5).
- ASSISTANT_SYSTEM_PROMPT documents [SUGGEST_FIX] (when to/not to emit,
  format, supersession semantics).
- 12 tests covering the parser (well-formed, last-wins, malformed,
  confidence clamping), supersession + state_version invariant, all
  decision branches, preview cache hit-on-no-change + miss-after-write.

Frontend:
- src/components/pilot/sections/SuggestedFix.tsx — amber-accented card
  with confidence badge; dismiss action wired to the decision endpoint.
- src/components/pilot/ResolutionNotePreview.tsx — popover with refresh,
  loading state, cached/fresh indicator, ticket-ref display.
- src/api/sessionSuggestedFixes.ts — typed client; getActive normalizes
  404 to null so callers don't have to special-case.
- TaskLane gains suggestedFixSlot + bottomSlot props (rendered after
  Diagnostic Checks; bottomSlot anchors the Resolve action).
- AssistantChatPage: refreshSessionDerived helper batches fact + fix
  refresh; fact mutations and chat sends both schedule a 500ms-debounced
  preview refresh per the Section 5.5 spec.

Verified end-to-end against the dev stack with a real Sonnet call:
- /active 404 → fact create → preview generates four-section markdown
  grounded only in provided facts → second preview call hits cache
  (from_cache=true, no LLM call) → fact write 2 → cache miss, regenerates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:45:52 -04:00
parent 625dba7548
commit 66e592096c
16 changed files with 1617 additions and 22 deletions

View File

@@ -0,0 +1,84 @@
/**
* Session suggested-fix + resolution-note preview API (Phase 3).
*
* Mirrors backend endpoints under /api/v1/ai-sessions/{id}/...
* See FLOWPILOT-MIGRATION.md Sections 5.2 + 5.4.
*/
import apiClient from './client'
export type UserDecision = 'one_off' | 'draft_template' | 'build_template' | 'dismissed'
export interface SessionSuggestedFix {
id: string
session_id: string
title: string
description: string
confidence_pct: number
script_template_id: string | null
ai_drafted_script: string | null
ai_drafted_parameters: Record<string, unknown> | null
user_decision: UserDecision | null
superseded_at: string | null
created_at: string
}
export interface DecisionResponse {
id: string
user_decision: UserDecision
rendered_script: string | null
redirect_path: string | null
}
export interface ResolutionNotePreview {
markdown: string
target_ticket_ref: string | null
state_version: number
from_cache: boolean
}
export const sessionSuggestedFixesApi = {
/**
* Returns the active suggested fix for a session, or `null` if there isn't one.
* The endpoint returns 404 in the no-fix case, which is normal — we coerce
* to null so callers don't have to distinguish "no fix" from "request failed".
*/
async getActive(sessionId: string): Promise<SessionSuggestedFix | null> {
try {
const r = await apiClient.get<SessionSuggestedFix>(
`/ai-sessions/${sessionId}/suggested-fixes/active`,
)
return r.data
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status === 404) return null
throw err
}
},
async recordDecision(
sessionId: string,
fixId: string,
decision: UserDecision,
): Promise<DecisionResponse> {
const r = await apiClient.post<DecisionResponse>(
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/decision`,
{ decision },
)
return r.data
},
/**
* Fetch (or get cached) draft markdown for the Resolve note. Backend cache
* is keyed on state_version, so calling this back-to-back without intervening
* fact / suggested-fix / script-generation writes returns the same payload
* cheaply (no Sonnet call).
*/
async getResolutionNotePreview(sessionId: string): Promise<ResolutionNotePreview> {
const r = await apiClient.post<ResolutionNotePreview>(
`/ai-sessions/${sessionId}/resolution-note/preview`,
)
return r.data
},
}
export default sessionSuggestedFixesApi

View File

@@ -43,6 +43,12 @@ interface TaskLaneProps {
// shape lets the parent own fact-fetching and state-version polling without
// pulling that concern into TaskLane.
whatWeKnowSlot?: React.ReactNode
// Phase 3: Suggested fix card, rendered below Diagnostic Checks.
suggestedFixSlot?: React.ReactNode
// Phase 3: bottom-of-lane slot for the Resolve action bar + preview popover
// (parent owns state). Renders inside the scrollable body so the popover
// stays anchored as the lane scrolls.
bottomSlot?: React.ReactNode
}
// ── Storage helpers ──
@@ -69,7 +75,7 @@ export function clearTaskState(sessionId: string) {
// ── Component ──
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot }: TaskLaneProps) {
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot }: TaskLaneProps) {
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
// Try to restore saved state for this session (preserves user's in-progress answers)
if (sessionId) {
@@ -503,6 +509,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
})}
</section>
)}
{/* ── Suggested fix (Phase 3) ── */}
{suggestedFixSlot}
{/* ── Resolve action bar + preview popover (Phase 3) ── */}
{bottomSlot}
</div>
{/* Footer */}

View File

@@ -0,0 +1,107 @@
/**
* ResolutionNotePreview — Phase 3 popover anchored to the Resolve action area.
*
* Persistent (not modal) popover showing the four-section draft markdown that
* would be posted to the customer ticket on Resolve. Per FLOWPILOT-MIGRATION.md
* Section 3.1, the engineer reviews/edits the draft inline and Confirm & post
* fires the PSA writeback (wired in Phase 4 — for now this is read-only).
*
* Refresh policy: parent triggers `onRefresh` when state_version changes.
* Backend caches by state_version, so repeat fetches are cheap (no Sonnet
* call) when no facts/fixes/scripts have changed.
*/
import { useState } from 'react'
import { Loader2, RefreshCw, X, FileText } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import type { ResolutionNotePreview as PreviewData } from '@/api/sessionSuggestedFixes'
interface ResolutionNotePreviewProps {
open: boolean
loading: boolean
preview: PreviewData | null
error: string | null
onClose: () => void
onRefresh: () => Promise<void> | void
}
export function ResolutionNotePreview({
open,
loading,
preview,
error,
onClose,
onRefresh,
}: ResolutionNotePreviewProps) {
const [refreshing, setRefreshing] = useState(false)
if (!open) return null
const handleRefresh = async () => {
setRefreshing(true)
try { await onRefresh() } finally { setRefreshing(false) }
}
return (
// The popover is positioned absolutely against its anchor by the parent.
// We render full-width inside the task lane below the Resolve action bar.
<div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
<div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page">
<div className="flex items-center gap-2">
<FileText size={13} className="text-accent" />
<span className="text-[0.75rem] font-semibold text-heading">
Resolution note preview
</span>
{preview?.target_ticket_ref && (
<span className="text-[0.6875rem] font-mono text-accent-text">
{preview.target_ticket_ref}
</span>
)}
{preview?.from_cache && (
<span className="text-[0.6875rem] text-muted-foreground italic">cached</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
title="Refresh preview"
>
{refreshing ? <Loader2 size={11} className="animate-spin" /> : <RefreshCw size={11} />}
</button>
<button
onClick={onClose}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
title="Close preview"
>
<X size={11} />
</button>
</div>
</div>
<div className="px-3 py-3 max-h-[40vh] overflow-y-auto">
{loading && !preview && (
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
<Loader2 size={12} className="animate-spin" />
Drafting note from session state...
</div>
)}
{error && (
<div className="text-[0.75rem] text-danger">{error}</div>
)}
{preview && (
<div className="prose prose-invert prose-sm max-w-none text-[0.8125rem] leading-relaxed">
<MarkdownContent content={preview.markdown} />
</div>
)}
{!loading && !error && !preview && (
<div className="text-[0.75rem] text-muted-foreground italic">
No preview yet add a fact or accept a suggested fix to populate.
</div>
)}
</div>
</div>
)
}
export default ResolutionNotePreview

View File

@@ -0,0 +1,82 @@
/**
* SuggestedFix card — Phase 3 task-lane section.
*
* Renders the active AI-proposed resolution path for the session
* (per FLOWPILOT-MIGRATION.md Section 3.1, "Suggested fix"). Amber-accented
* to match the mockup; clicking opens the Script Generator flow in Phase 5.
*
* For Phase 3, the card is informational + a Dismiss action. The three-option
* dialog (one_off / draft_template / build_template) is wired in Phase 5
* via a separate component.
*/
import { useState } from 'react'
import { Sparkles, X } from 'lucide-react'
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
interface SuggestedFixProps {
fix: SessionSuggestedFix
onDismiss: () => Promise<void> | void
}
function confidenceBucket(pct: number): { label: string; tone: string } {
if (pct >= 80) return { label: 'high', tone: 'text-success' }
if (pct >= 50) return { label: 'medium', tone: 'text-warning' }
return { label: 'low', tone: 'text-muted-foreground' }
}
export function SuggestedFix({ fix, onDismiss }: SuggestedFixProps) {
const [busy, setBusy] = useState(false)
const conf = confidenceBucket(fix.confidence_pct)
const handleDismiss = async () => {
setBusy(true)
try { await onDismiss() } finally { setBusy(false) }
}
return (
<section>
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-warning" />
Suggested fix
<span className="text-muted-foreground">·</span>
<span className={`tabular-nums ${conf.tone}`}>{fix.confidence_pct}% confidence</span>
</div>
</div>
<div className="rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2">
<div className="flex items-start gap-2">
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-medium text-heading leading-snug">
{fix.title}
</div>
<div className="mt-1 text-[0.75rem] text-muted-foreground leading-relaxed">
{fix.description}
</div>
{fix.script_template_id && (
<div className="mt-1.5 text-[0.6875rem] text-success">
Matches an existing Script Library template
</div>
)}
{!fix.script_template_id && fix.ai_drafted_script && (
<div className="mt-1.5 text-[0.6875rem] text-accent-text">
Custom script drafted (no template match)
</div>
)}
</div>
<button
onClick={handleDismiss}
disabled={busy}
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
title="Dismiss this suggestion"
>
<X size={11} />
</button>
</div>
</div>
</section>
)
}
export default SuggestedFix

View File

@@ -14,7 +14,14 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
import {
sessionSuggestedFixesApi,
type SessionSuggestedFix,
type ResolutionNotePreview as ResolutionNotePreviewData,
} from '@/api/sessionSuggestedFixes'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -80,6 +87,16 @@ export default function AssistantChatPage() {
// selectChat and after each chat send (the AI may have emitted [PROMOTE]
// markers that synthesized new facts server-side).
const [facts, setFacts] = useState<SessionFact[]>([])
// Phase 3: active suggested fix + resolution-note preview state.
const [activeFix, setActiveFix] = useState<SessionSuggestedFix | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
// Debounce timer for preview refresh — Phase 3 spec calls for 500ms client-
// side debounce so rapid edits don't fan out to the LLM (cache absorbs the
// dups, but the request itself still costs HTTP RTT).
const previewDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -184,8 +201,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Refetch facts — the AI may have emitted [PROMOTE] markers.
refreshFacts(session.session_id)
// Refetch facts + active fix — the AI may have emitted markers.
refreshSessionDerived(session.session_id)
} catch {
toast.error('Failed to start AI conversation')
} finally {
@@ -250,11 +267,20 @@ export default function AssistantChatPage() {
}
}, [])
// Phase 3: convenience helper — refresh fact list, active fix, and (if open)
// schedule a preview refresh. Called after every chat send so the new state
// (PROMOTE-synthesized facts, new SUGGEST_FIX) appears in the lane.
const refreshSessionDerived = useCallback(async (chatId: string) => {
await Promise.all([refreshFacts(chatId), refreshActiveFix(chatId)])
if (previewOpen) schedulePreviewRefresh(chatId)
}, [refreshFacts, refreshActiveFix, previewOpen, schedulePreviewRefresh])
const handleAddNote = async (text: string, summary: string | null) => {
if (!activeChatId) return
try {
const fact = await sessionFactsApi.create(activeChatId, { text, summary })
setFacts(prev => [...prev, fact])
schedulePreviewRefresh(activeChatId)
} catch {
toast.error('Failed to add note')
}
@@ -265,6 +291,7 @@ export default function AssistantChatPage() {
try {
const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary })
setFacts(prev => prev.map(f => f.id === factId ? updated : f))
schedulePreviewRefresh(activeChatId)
} catch {
toast.error('Failed to update fact')
}
@@ -275,11 +302,77 @@ export default function AssistantChatPage() {
try {
await sessionFactsApi.remove(activeChatId, factId)
setFacts(prev => prev.filter(f => f.id !== factId))
schedulePreviewRefresh(activeChatId)
} catch {
toast.error('Failed to remove fact')
}
}
// Phase 3 — active suggested fix + resolution-note preview.
const refreshActiveFix = useCallback(async (chatId: string) => {
try {
const fix = await sessionSuggestedFixesApi.getActive(chatId)
if (currentChatRef.current !== chatId) return
setActiveFix(fix)
} catch {
// No-fix-yet (404) is normalized to null inside the client. Genuine
// failures stay silent — accessory state, not load-bearing.
}
}, [])
const refreshPreview = useCallback(async (chatId: string) => {
setPreviewLoading(true)
setPreviewError(null)
try {
const p = await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
if (currentChatRef.current !== chatId) return
setPreviewData(p)
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
setPreviewError(
status === 502
? 'AI provider error drafting the note. Try again in a few seconds.'
: 'Could not load preview.',
)
} finally {
setPreviewLoading(false)
}
}, [])
// Trigger preview refresh with a 500ms debounce. The backend cache short-
// circuits same-state calls, but the network round-trip is still avoidable
// when the user is typing quickly (e.g. editing a fact).
const schedulePreviewRefresh = useCallback((chatId: string) => {
if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current)
previewDebounceRef.current = setTimeout(() => {
if (previewOpen && currentChatRef.current === chatId) {
refreshPreview(chatId)
}
}, 500)
}, [previewOpen, refreshPreview])
const handleDismissFix = async () => {
if (!activeChatId || !activeFix) return
try {
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
setActiveFix(null)
// Dismissal bumps state_version on the server; reflect in preview.
schedulePreviewRefresh(activeChatId)
} catch {
toast.error('Failed to dismiss suggestion')
}
}
const handleTogglePreview = () => {
if (!activeChatId) return
const next = !previewOpen
setPreviewOpen(next)
if (next && !previewData) {
// First open — fetch immediately, no debounce.
refreshPreview(activeChatId)
}
}
const selectChat = useCallback(async (chatId: string) => {
currentChatRef.current = chatId
setActiveChatId(chatId)
@@ -290,8 +383,12 @@ export default function AssistantChatPage() {
setActiveSessionStatus(null)
setActivePsaTicketId(null)
setFacts([])
// Fire facts fetch in parallel with session detail.
refreshFacts(chatId)
setActiveFix(null)
setPreviewData(null)
setPreviewError(null)
setPreviewOpen(false)
// Fire facts + active-fix fetches in parallel with session detail.
refreshSessionDerived(chatId)
try {
const detail = await aiSessionsApi.getSession(chatId)
// Guard: if the user switched to a different chat while this API call was
@@ -327,7 +424,7 @@ export default function AssistantChatPage() {
} catch {
setMessages([])
}
}, [refreshFacts])
}, [refreshSessionDerived])
const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
@@ -428,8 +525,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Refetch facts — [PROMOTE] markers may have synthesized new ones.
refreshFacts(sentForChatId)
// Refetch facts + active fix; preview refreshes if open.
refreshSessionDerived(sentForChatId)
} catch (err: unknown) {
console.error('[AssistantChat] sendChatMessage failed:', err)
const status = (err as { response?: { status?: number } })?.response?.status
@@ -498,8 +595,8 @@ export default function AssistantChatPage() {
setActiveQuestions([])
setActiveActions([])
}
// Refetch facts answering tasks is the primary [PROMOTE] trigger.
refreshFacts(sentForChatId)
// Refetch facts + active fix; answering tasks is the primary trigger.
refreshSessionDerived(sentForChatId)
} catch (err: unknown) {
console.error('[AssistantChat] handleTaskSubmit failed:', err)
const status = (err as { response?: { status?: number } })?.response?.status
@@ -589,8 +686,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Refetch facts — the resume turn may emit [PROMOTE] markers.
refreshFacts(session.session_id)
// Refetch facts + active fix — resume turn may emit markers.
refreshSessionDerived(session.session_id)
} catch {
toast.error('Failed to create resume chat')
} finally {
@@ -1080,10 +1177,10 @@ export default function AssistantChatPage() {
</div>
{/* Task lane — slides in when AI sends questions/actions OR when the
session has any "What we know" facts. Phase 2 makes the lane the
structural home of session diagnostic state, not a transient
questions panel. */}
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0) && (
session has any "What we know" facts OR an active suggested fix.
Phase 2/3 make the lane the structural home of session diagnostic
state, not a transient questions panel. */}
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
<TaskLane
questions={activeQuestions}
actions={activeActions}
@@ -1101,6 +1198,30 @@ export default function AssistantChatPage() {
onDeleteFact={handleDeleteFact}
/>
}
suggestedFixSlot={
activeFix && (
<SuggestedFix fix={activeFix} onDismiss={handleDismissFix} />
)
}
bottomSlot={
<>
<button
onClick={handleTogglePreview}
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-heading transition-colors px-3 mt-1"
>
<FileText size={12} />
{previewOpen ? 'Hide' : 'Preview'} Resolve note
</button>
<ResolutionNotePreviewPopover
open={previewOpen}
loading={previewLoading}
preview={previewData}
error={previewError}
onClose={() => setPreviewOpen(false)}
onRefresh={() => activeChatId && refreshPreview(activeChatId)}
/>
</>
}
/>
)}