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

@@ -38,6 +38,11 @@ interface TaskLaneProps {
onSubmit: (responses: TaskResponse[]) => void
onClose: () => void
loading?: boolean
// Slot for the FlowPilot Phase 2 "What we know" section. Rendered above
// Questions in the body (per FLOWPILOT-MIGRATION.md Section 3.1). The slot
// shape lets the parent own fact-fetching and state-version polling without
// pulling that concern into TaskLane.
whatWeKnowSlot?: React.ReactNode
}
// ── Storage helpers ──
@@ -64,7 +69,7 @@ export function clearTaskState(sessionId: string) {
// ── Component ──
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) {
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot }: TaskLaneProps) {
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
// Try to restore saved state for this session (preserves user's in-progress answers)
if (sessionId) {
@@ -269,6 +274,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{/* Body */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* ── What we know (Phase 2) ── */}
{whatWeKnowSlot}
{/* ── Questions Section ── */}
{questionTasks.length > 0 && (
<section>

View File

@@ -0,0 +1,87 @@
/**
* "+ Add a note" affordance for the What-we-know section.
*
* Inline composer that posts a `user_note` fact when the engineer wants to
* record something the AI didn't surface (a hunch, an observation, a piece
* of customer context). Per FLOWPILOT-MIGRATION.md Section 3.1.
*/
import { useState } from 'react'
import { Plus, Check } from 'lucide-react'
interface AddNoteButtonProps {
onAdd: (text: string, summary: string | null) => Promise<void> | void
}
export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
const [open, setOpen] = useState(false)
const [text, setText] = useState('')
const [summary, setSummary] = useState('')
const [busy, setBusy] = useState(false)
const reset = () => {
setText('')
setSummary('')
setOpen(false)
}
const handleSubmit = async () => {
if (!text.trim()) return
setBusy(true)
try {
await onAdd(text.trim(), summary.trim() || null)
reset()
} finally {
setBusy(false)
}
}
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-[0.75rem] text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
>
<Plus size={12} />
Add a note
</button>
)
}
return (
<div className="rounded-lg border border-default bg-card p-3 mb-2">
<textarea
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What did you observe or confirm?"
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[44px] max-h-[140px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={2}
/>
<input
type="text"
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="Short label (optional)"
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/>
<div className="mt-1.5 flex items-center gap-2">
<button
onClick={handleSubmit}
disabled={busy || !text.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
>
<Check size={11} /> Add
</button>
<button
onClick={reset}
disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Cancel
</button>
</div>
</div>
)
}
export default AddNoteButton

View File

@@ -0,0 +1,74 @@
/**
* What-we-know section — the load-bearing structural feature of the FlowPilot
* task lane (per FLOWPILOT-MIGRATION.md Section 0 architectural claim #2).
*
* Owns the list of confirmed facts for a session. Facts arrive via three paths:
* 1. AI [PROMOTE] markers parsed in unified_chat_service (most common)
* 2. Engineer "+ Add a note" (manual user_note)
* 3. Engineer-initiated promotion of a question/check (future affordance)
*
* This component is the parent's contract: it takes a fact list + handlers
* and renders the section. Loading/refresh logic lives in the parent
* (AssistantChatPage) so it can coordinate with the chat send cycle.
*/
import { cn } from '@/lib/utils'
import type { SessionFact } from '@/api/sessionFacts'
import { WhatWeKnowItem } from './WhatWeKnowItem'
import { AddNoteButton } from './AddNoteButton'
interface WhatWeKnowProps {
facts: SessionFact[]
onAddNote: (text: string, summary: string | null) => Promise<void> | void
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
onDeleteFact: (factId: string) => Promise<void> | void
loading?: boolean
}
export function WhatWeKnow({
facts,
onAddNote,
onUpdateFact,
onDeleteFact,
loading,
}: WhatWeKnowProps) {
const count = facts.length
return (
<section
className={cn(
'rounded-lg p-3 -mx-1 mb-1',
// Subtle green-to-transparent gradient distinguishes this section
// from the rest of the lane (mockup 01-session-primary.png).
'bg-gradient-to-b from-success/[0.05] to-transparent',
)}
>
<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-success" />
What we know
<span className="text-muted-foreground">·</span>
<span className="tabular-nums">{count}</span>
</div>
</div>
{count === 0 && !loading && (
<div className="text-[0.75rem] text-muted-foreground italic px-1 py-2">
Nothing confirmed yet facts appear here as the engineer answers questions and runs checks.
</div>
)}
{facts.map((fact) => (
<WhatWeKnowItem
key={fact.id}
fact={fact}
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
onDelete={() => onDeleteFact(fact.id)}
/>
))}
<AddNoteButton onAdd={onAddNote} />
</section>
)
}
export default WhatWeKnow

View File

@@ -0,0 +1,152 @@
/**
* Single fact card in the What-we-know section.
*
* Layout per FLOWPILOT-MIGRATION.md Section 3.1:
* [✓] <fact text>
* <provenance line: 'from question · rules out tenant/license'>
*
* Editability: the server marks `editable=false` for `question` and
* `diagnostic_check` facts (those are edited at the source item, not here).
* Manual notes and AI-synthesized facts are editable inline.
*
* Delete is allowed for all source types — even read-only facts may be
* removed when the underlying question or check turned out to be wrong.
*/
import { useState } from 'react'
import { Check, Pencil, Trash2, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionFact, SessionFactSourceType } from '@/api/sessionFacts'
interface WhatWeKnowItemProps {
fact: SessionFact
onSave: (text: string, summary: string | null) => Promise<void> | void
onDelete: () => Promise<void> | void
}
const SOURCE_LABEL: Record<SessionFactSourceType, string> = {
question: 'from question',
diagnostic_check: 'from check',
user_note: 'manual note',
ai_synthesis: 'synthesized',
}
export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps) {
const [editing, setEditing] = useState(false)
const [draftText, setDraftText] = useState(fact.text)
const [draftSummary, setDraftSummary] = useState(fact.source_summary ?? '')
const [busy, setBusy] = useState(false)
const handleSave = async () => {
if (!draftText.trim()) return
setBusy(true)
try {
await onSave(draftText.trim(), draftSummary.trim() || null)
setEditing(false)
} finally {
setBusy(false)
}
}
const handleCancel = () => {
setDraftText(fact.text)
setDraftSummary(fact.source_summary ?? '')
setEditing(false)
}
const handleDelete = async () => {
setBusy(true)
try {
await onDelete()
} finally {
setBusy(false)
}
}
const provenanceLabel = SOURCE_LABEL[fact.source_type]
if (editing) {
return (
<div className="rounded-lg border border-default bg-card p-3 mb-2">
<textarea
autoFocus
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading resize-y min-h-[44px] max-h-[140px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
rows={2}
/>
<input
type="text"
value={draftSummary}
onChange={(e) => setDraftSummary(e.target.value)}
placeholder="Short label (e.g. 'rules out tenant/license')"
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
/>
<div className="mt-1.5 flex items-center gap-2">
<button
onClick={handleSave}
disabled={busy || !draftText.trim()}
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
>
<Check size={11} /> Save
</button>
<button
onClick={handleCancel}
disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading"
>
Cancel
</button>
</div>
</div>
)
}
return (
<div
className={cn(
'group rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/20 p-3 mb-2',
busy && 'opacity-60',
)}
>
<div className="flex items-start gap-2">
<div className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-dashed border-success">
<Check size={9} className="text-success" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] text-heading leading-relaxed">{fact.text}</div>
<div className="mt-1 text-[0.6875rem] text-muted-foreground">
<span>{provenanceLabel}</span>
{fact.source_summary && (
<>
<span className="mx-1">·</span>
<span className="italic">{fact.source_summary}</span>
</>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{fact.editable && (
<button
onClick={() => setEditing(true)}
disabled={busy}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
title="Edit fact"
>
<Pencil size={11} />
</button>
)}
<button
onClick={handleDelete}
disabled={busy}
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
title="Remove fact"
>
{busy ? <X size={11} /> : <Trash2 size={11} />}
</button>
</div>
</div>
</div>
)
}
export default WhatWeKnowItem