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:
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user