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

@@ -0,0 +1,89 @@
/**
* Session facts API — the "What we know" CRUD surface for a FlowPilot session.
*
* Mirrors backend endpoints at `/api/v1/ai-sessions/{id}/facts`.
* See FLOWPILOT-MIGRATION.md Section 5.1.
*/
import apiClient from './client'
export type SessionFactSourceType =
| 'question'
| 'diagnostic_check'
| 'user_note'
| 'ai_synthesis'
export interface SessionFact {
id: string
session_id: string
text: string
source_type: SessionFactSourceType
source_ref: string | null
source_summary: string | null
created_by: string
created_at: string
updated_at: string
// Server-computed: false for question/diagnostic_check (PATCH returns 403),
// true for user_note/ai_synthesis. Drives the edit affordance in the UI.
editable: boolean
}
export interface SessionFactCreateRequest {
text: string
summary?: string | null
}
export interface SessionFactUpdateRequest {
text?: string | null
summary?: string | null
}
export interface SessionFactPromoteRequest {
source_type: 'question' | 'diagnostic_check' | 'ai_synthesis'
source_ref?: string | null
proposed_text?: string | null
proposed_summary?: string | null
raw_input?: string | null
}
export const sessionFactsApi = {
async list(sessionId: string): Promise<SessionFact[]> {
const r = await apiClient.get<{ facts: SessionFact[] }>(
`/ai-sessions/${sessionId}/facts`,
)
return r.data.facts
},
async create(sessionId: string, data: SessionFactCreateRequest): Promise<SessionFact> {
const r = await apiClient.post<SessionFact>(
`/ai-sessions/${sessionId}/facts`,
data,
)
return r.data
},
async update(
sessionId: string,
factId: string,
data: SessionFactUpdateRequest,
): Promise<SessionFact> {
const r = await apiClient.patch<SessionFact>(
`/ai-sessions/${sessionId}/facts/${factId}`,
data,
)
return r.data
},
async remove(sessionId: string, factId: string): Promise<void> {
await apiClient.delete(`/ai-sessions/${sessionId}/facts/${factId}`)
},
async promote(sessionId: string, data: SessionFactPromoteRequest): Promise<SessionFact> {
const r = await apiClient.post<SessionFact>(
`/ai-sessions/${sessionId}/facts/promote`,
data,
)
return r.data
},
}
export default sessionFactsApi