feat(pilot): Phase 2 — What we know (facts) with stable task-lane IDs

Adds the load-bearing structural feature of the FlowPilot migration: a
"What we know" panel that holds confirmed facts for a session, fed by AI
[PROMOTE] markers and engineer-added notes. Facts feed the resolution
note preview (Phase 3) and survive across turns via stable UUIDs assigned
to pending_task_lane items.

Backend:
- FactSynthesisService: create/update/soft-delete facts with atomic
  state_version bumps; LLM-backed synthesize_from_question/check on the
  fact_synthesis (Haiku) action tier per Section 6.6.
- /api/v1/ai-sessions/{id}/facts CRUD + /facts/promote (proposed_text or
  via synthesis). PATCH returns 403 for question/diagnostic_check facts
  (edit the source item instead, Section 7.3).
- unified_chat_service: [PROMOTE] marker parser (JSON-block per Section
  8.1 spec drift note), stable-UUID assignment for pending_task_lane
  questions/actions preserved by exact text/label match across turns.
- ASSISTANT_SYSTEM_PROMPT: documents [PROMOTE] format, when to/not to
  emit, hallucination guardrails, source_ref handling.
- 17 tests covering parser, stable IDs, service validation, CRUD,
  editability rule, both promote modes, 422 null-synthesis path,
  state_version invariant.

Frontend:
- src/components/pilot/sections/{WhatWeKnow,WhatWeKnowItem,AddNoteButton}
  — green-gradient section above Questions, dashed-circle check, inline
  edit/delete gated by the server's editable flag.
- TaskLane gains a whatWeKnowSlot prop (existing assistant/ folder kept
  per the doc's "rename is opportunistic" guidance).
- AssistantChatPage fetches facts on selectChat and refetches after each
  chat send (so [PROMOTE]-synthesized facts appear immediately); auto-
  opens the lane when facts exist.

Verification: end-to-end smoke against the local docker stack confirms
all five endpoints (list/create/patch/delete/promote) plus the 403
editability rule. pytest suite verifies the same with mocked LLM. Live
[PROMOTE] flow remains untested until used in the UI — the marker shape
is covered by parser tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 21:13:44 -04:00
parent 19cfd71995
commit 625dba7548
15 changed files with 1922 additions and 21 deletions

View File

@@ -13,6 +13,8 @@ import { toast } from '@/lib/toast'
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -74,6 +76,10 @@ export default function AssistantChatPage() {
)
const [activeSessionStatus, setActiveSessionStatus] = useState<string | null>(null)
const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null)
// Phase 2: "What we know" facts for the active session. Refreshed on
// selectChat and after each chat send (the AI may have emitted [PROMOTE]
// markers that synthesized new facts server-side).
const [facts, setFacts] = useState<SessionFact[]>([])
const [showOverflow, setShowOverflow] = useState(false)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -178,6 +184,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Refetch facts — the AI may have emitted [PROMOTE] markers.
refreshFacts(session.session_id)
} catch {
toast.error('Failed to start AI conversation')
} finally {
@@ -222,6 +230,56 @@ export default function AssistantChatPage() {
}
}
// Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
// and after each chat send, because the AI may have emitted [PROMOTE] markers
// that synthesized new facts server-side (see unified_chat_service.
// _persist_promote_items).
const refreshFacts = useCallback(async (chatId: string) => {
try {
const list = await sessionFactsApi.list(chatId)
// Guard: discard stale fetch if the user switched chats mid-flight.
if (currentChatRef.current !== chatId) return
setFacts(list)
// Auto-open the task lane when the session has facts so the engineer
// can see them — without this, a session with only facts (no open
// questions) would hide the lane and the facts would be invisible.
if (list.length > 0) setShowTaskLane(true)
} catch {
// Best-effort — facts are accessory state. Surfacing a toast on every
// refetch failure would be noisy; the empty state explains the absence.
}
}, [])
const handleAddNote = async (text: string, summary: string | null) => {
if (!activeChatId) return
try {
const fact = await sessionFactsApi.create(activeChatId, { text, summary })
setFacts(prev => [...prev, fact])
} catch {
toast.error('Failed to add note')
}
}
const handleUpdateFact = async (factId: string, text: string, summary: string | null) => {
if (!activeChatId) return
try {
const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary })
setFacts(prev => prev.map(f => f.id === factId ? updated : f))
} catch {
toast.error('Failed to update fact')
}
}
const handleDeleteFact = async (factId: string) => {
if (!activeChatId) return
try {
await sessionFactsApi.remove(activeChatId, factId)
setFacts(prev => prev.filter(f => f.id !== factId))
} catch {
toast.error('Failed to remove fact')
}
}
const selectChat = useCallback(async (chatId: string) => {
currentChatRef.current = chatId
setActiveChatId(chatId)
@@ -231,6 +289,9 @@ export default function AssistantChatPage() {
setActiveActions([])
setActiveSessionStatus(null)
setActivePsaTicketId(null)
setFacts([])
// Fire facts fetch in parallel with session detail.
refreshFacts(chatId)
try {
const detail = await aiSessionsApi.getSession(chatId)
// Guard: if the user switched to a different chat while this API call was
@@ -266,7 +327,7 @@ export default function AssistantChatPage() {
} catch {
setMessages([])
}
}, [])
}, [refreshFacts])
const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
@@ -277,6 +338,7 @@ export default function AssistantChatPage() {
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
setFacts([])
setMessages([])
setActiveSessionStatus('active')
setActivePsaTicketId(null)
@@ -366,6 +428,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Refetch facts — [PROMOTE] markers may have synthesized new ones.
refreshFacts(sentForChatId)
} catch (err: unknown) {
console.error('[AssistantChat] sendChatMessage failed:', err)
const status = (err as { response?: { status?: number } })?.response?.status
@@ -434,6 +498,8 @@ export default function AssistantChatPage() {
setActiveQuestions([])
setActiveActions([])
}
// Refetch facts — answering tasks is the primary [PROMOTE] trigger.
refreshFacts(sentForChatId)
} catch (err: unknown) {
console.error('[AssistantChat] handleTaskSubmit failed:', err)
const status = (err as { response?: { status?: number } })?.response?.status
@@ -523,6 +589,8 @@ export default function AssistantChatPage() {
setActiveActions(response.actions || [])
setShowTaskLane(true)
}
// Refetch facts — the resume turn may emit [PROMOTE] markers.
refreshFacts(session.session_id)
} catch {
toast.error('Failed to create resume chat')
} finally {
@@ -1011,8 +1079,11 @@ export default function AssistantChatPage() {
)}
</div>
{/* Task lane — slides in when AI sends questions or actions */}
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
{/* 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) && (
<TaskLane
questions={activeQuestions}
actions={activeActions}
@@ -1022,6 +1093,14 @@ export default function AssistantChatPage() {
setShowTaskLane(false)
}}
loading={loading}
whatWeKnowSlot={
<WhatWeKnow
facts={facts}
onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact}
onDeleteFact={handleDeleteFact}
/>
}
/>
)}