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,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