mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
e4fc345912
commit
86935bbb2b
2 changed files with 188 additions and 6 deletions
139
attractor-web/src/api.ts
Normal file
139
attractor-web/src/api.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue