Expose MultipleChoice options in API and support selected answers

ApiQuestion now includes options and allow_freeform fields so API clients
can render multiple-choice questions. submit_answer accepts an optional
selected_option_key to produce AnswerValue::Selected instead of always
creating AnswerValue::Text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 13:28:36 -05:00
parent e4fc345912
commit 86935bbb2b
2 changed files with 188 additions and 6 deletions

139
attractor-web/src/api.ts Normal file
View file

@ -0,0 +1,139 @@
// Pipeline API types and fetch wrappers
export type PipelineStatus = "running" | "completed" | "failed" | "cancelled";
export interface PipelineStatusResponse {
id: string;
status: PipelineStatus;
error?: string;
}
export interface StartPipelineResponse {
id: string;
}
export interface ApiQuestionOption {
key: string;
label: string;
}
export interface ApiQuestion {
id: string;
text: string;
question_type: string;
options: ApiQuestionOption[];
allow_freeform: boolean;
}
export interface SubmitAnswerResponse {
accepted: boolean;
}
export interface Checkpoint {
timestamp: string;
current_node: string;
completed_nodes: string[];
node_retries: Record<string, number>;
context_values: Record<string, unknown>;
logs: string[];
node_outcomes: Record<string, Outcome>;
next_node_id?: string;
}
export interface Outcome {
status: string;
preferred_label?: string;
suggested_next_ids: string[];
context_updates: Record<string, unknown>;
notes?: string;
failure_reason?: string;
}
export type PipelineEvent =
| { PipelineStarted: { name: string; id: string } }
| { PipelineCompleted: { duration_ms: number; artifact_count: number } }
| { PipelineFailed: { error: string; duration_ms: number } }
| { StageStarted: { name: string; index: number } }
| { StageCompleted: { name: string; index: number; duration_ms: number } }
| { StageFailed: { name: string; index: number; error: string; will_retry: boolean } }
| { StageRetrying: { name: string; index: number; attempt: number; delay_ms: number } }
| { ParallelStarted: { branch_count: number } }
| { ParallelBranchStarted: { branch: string; index: number } }
| { ParallelBranchCompleted: { branch: string; index: number; duration_ms: number; success: boolean } }
| { ParallelCompleted: { duration_ms: number; success_count: number; failure_count: number } }
| { InterviewStarted: { question: string; stage: string } }
| { InterviewCompleted: { question: string; answer: string; duration_ms: number } }
| { InterviewTimeout: { question: string; stage: string; duration_ms: number } }
| { CheckpointSaved: { node_id: string } };
export type ContextSnapshot = Record<string, unknown>;
const API_BASE = "/api";
export async function startPipeline(dotSource: string): Promise<StartPipelineResponse> {
const res = await fetch(`${API_BASE}/pipelines`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ dot_source: dotSource }),
});
if (!res.ok) throw new Error(`Start failed: ${res.status}`);
return res.json() as Promise<StartPipelineResponse>;
}
export async function getPipelineStatus(id: string): Promise<PipelineStatusResponse> {
const res = await fetch(`${API_BASE}/pipelines/${id}`);
if (!res.ok) throw new Error(`Status failed: ${res.status}`);
return res.json() as Promise<PipelineStatusResponse>;
}
export async function getQuestions(id: string): Promise<ApiQuestion[]> {
const res = await fetch(`${API_BASE}/pipelines/${id}/questions`);
if (!res.ok) throw new Error(`Questions failed: ${res.status}`);
return res.json() as Promise<ApiQuestion[]>;
}
export async function submitAnswer(
pipelineId: string,
questionId: string,
value: string,
selectedOptionKey?: string,
): Promise<SubmitAnswerResponse> {
const body: Record<string, string> = { value };
if (selectedOptionKey !== undefined) {
body.selected_option_key = selectedOptionKey;
}
const res = await fetch(`${API_BASE}/pipelines/${pipelineId}/questions/${questionId}/answer`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Answer failed: ${res.status}`);
return res.json() as Promise<SubmitAnswerResponse>;
}
export async function getCheckpoint(id: string): Promise<Checkpoint | null> {
const res = await fetch(`${API_BASE}/pipelines/${id}/checkpoint`);
if (!res.ok) throw new Error(`Checkpoint failed: ${res.status}`);
return res.json() as Promise<Checkpoint | null>;
}
export async function getContext(id: string): Promise<ContextSnapshot> {
const res = await fetch(`${API_BASE}/pipelines/${id}/context`);
if (!res.ok) throw new Error(`Context failed: ${res.status}`);
return res.json() as Promise<ContextSnapshot>;
}
export async function cancelPipeline(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/pipelines/${id}/cancel`, { method: "POST" });
if (!res.ok) throw new Error(`Cancel failed: ${res.status}`);
}
export async function getGraph(id: string): Promise<string> {
const res = await fetch(`${API_BASE}/pipelines/${id}/graph`);
if (!res.ok) throw new Error(`Graph failed: ${res.status}`);
return res.text();
}
export function eventsUrl(id: string): string {
return `${API_BASE}/pipelines/${id}/events`;
}

View file

@ -19,7 +19,7 @@ use crate::engine::{PipelineEngine, RunConfig};
use crate::event::{EventEmitter, PipelineEvent};
use crate::handler::HandlerRegistry;
use crate::interviewer::web::WebInterviewer;
use crate::interviewer::{Answer, AnswerValue, Interviewer};
use crate::interviewer::{Answer, Interviewer, QuestionOption};
/// Status of a managed pipeline.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -31,12 +31,21 @@ pub enum PipelineStatus {
Cancelled,
}
/// An option for a multiple-choice question exposed via the API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiQuestionOption {
pub key: String,
pub label: String,
}
/// A pending question exposed via the API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiQuestion {
pub id: String,
pub text: String,
pub question_type: String,
pub options: Vec<ApiQuestionOption>,
pub allow_freeform: bool,
}
/// Snapshot of a managed pipeline.
@ -83,6 +92,7 @@ pub struct PipelineStatusResponse {
#[derive(Debug, Deserialize)]
pub struct SubmitAnswerRequest {
pub value: String,
pub selected_option_key: Option<String>,
}
/// Response for answer submission.
@ -253,8 +263,18 @@ async fn get_questions(
.into_iter()
.map(|pq| ApiQuestion {
id: pq.id,
text: pq.question.text,
text: pq.question.text.clone(),
question_type: format!("{:?}", pq.question.question_type),
options: pq
.question
.options
.iter()
.map(|o| ApiQuestionOption {
key: o.key.clone(),
label: o.label.clone(),
})
.collect(),
allow_freeform: pq.question.allow_freeform,
})
.collect();
(StatusCode::OK, Json(questions)).into_response()
@ -271,10 +291,33 @@ async fn submit_answer(
let pipelines = state.pipelines.lock().expect("pipelines lock poisoned");
match pipelines.get(&id) {
Some(pipeline) => {
let answer = Answer {
value: AnswerValue::Text(req.value.clone()),
selected_option: None,
text: Some(req.value),
let answer = match &req.selected_option_key {
Some(key) => {
let option = pipeline
.interviewer
.pending_questions()
.iter()
.find(|pq| pq.id == qid)
.and_then(|pq| pq.question.options.iter().find(|o| o.key == *key))
.cloned();
match option {
Some(opt) => Answer::selected(
key.clone(),
QuestionOption {
key: opt.key,
label: opt.label,
},
),
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid option key"})),
)
.into_response();
}
}
}
None => Answer::text(req.value),
};
let accepted = pipeline.interviewer.submit_answer(&qid, answer);
(StatusCode::OK, Json(SubmitAnswerResponse { accepted })).into_response()