Task 008 implement boundary questions loop

This commit is contained in:
2026-05-21 19:46:26 +03:00
parent 8aa92ea728
commit b7e2fa34b2
27 changed files with 1883 additions and 24 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build",
"start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,14 @@
import ArticleBoundaryQuestionsPage from "@/pages/article-boundary-questions";
type DynamicArticleBoundaryQuestionsPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleBoundaryQuestionsPage({
params,
}: DynamicArticleBoundaryQuestionsPageProps) {
const { articleId } = params;
return <ArticleBoundaryQuestionsPage articleId={articleId} />;
}
+83
View File
@@ -224,6 +224,89 @@ main {
padding: 0 14px;
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.sectionHeader h2,
.sectionHeader p {
margin: 0;
}
.sectionHeader button {
border-radius: 6px;
border: 1px solid #1d2733;
background: #ffffff;
color: #1d2733;
min-height: 40px;
padding: 0 14px;
}
.boundaryQuestions {
display: grid;
gap: 16px;
}
.questionList {
display: grid;
gap: 12px;
}
.questionItem {
border: 1px solid #d0d5dd;
border-radius: 8px;
padding: 14px;
background: #ffffff;
}
.questionHeader {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: start;
}
.questionHeader p {
margin: 6px 0 10px;
color: #475467;
}
.questionItem textarea {
width: 100%;
min-height: 96px;
border: 1px solid #d0d5dd;
border-radius: 6px;
padding: 10px;
font: inherit;
}
.requiredBadge,
.optionalBadge {
display: inline-flex;
min-height: 28px;
align-items: center;
border-radius: 6px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.requiredBadge {
border: 1px solid #b42318;
color: #b42318;
background: #fff4f2;
}
.optionalBadge {
border: 1px solid #667085;
color: #344054;
background: #f9fafb;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
@@ -20,14 +20,14 @@ export function buildAgentJobRows(jobs: readonly AgentJobSummary[]): AgentJobRow
id: job.id,
type: job.job_type,
status: job.status,
attempt: job.attempt,
attempt: job.attempt ?? 1,
parentJobId: job.parent_job_id ?? null,
workspacePath: job.workspace_path ?? null,
stdout: job.stdout,
stderr: job.stderr,
stdout: job.stdout ?? "",
stderr: job.stderr ?? "",
exitCode: job.exit_code ?? null,
durationMs: job.duration_ms ?? null,
outputFiles: job.output_files.map((file) => file.path).join(", "),
outputFiles: (job.output_files ?? []).map((file) => file.path).join(", "),
error: job.error_category ?? job.error_message ?? "",
}));
}
@@ -16,12 +16,15 @@ const EMPTY_ASSIGNED_EDITOR_LABEL = "Unassigned";
export function getNextActionForStatus(status: string): string {
if (status === "ARTICLE_BRIEF_CREATED") {
return "Create plan";
}
if (status === "PLAN_DRAFTED") {
return "Run boundary questions";
}
if (status === "PLAN_REVIEWED") {
if (status === "BOUNDARY_QUESTIONS_GENERATED") {
return "Submit boundary answers";
}
if (status === "BOUNDARY_ANSWERS_SUBMITTED") {
return "Create plan";
}
if (status === "PLAN_REVIEW_REQUIRED") {
return "Gather evidence";
}
return "Continue workflow";
@@ -1,3 +1,5 @@
import Link from "next/link";
import type { DetailSummary } from "./model";
type DetailShellProps = {
@@ -36,6 +38,14 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<h3>Brief</h3>
<p>{summary.briefDescription}</p>
<ul className="inlineLinks">
<li>
<Link href={`/articles/${summary.articleId}/boundary-questions`}>
Boundary questions
</Link>
</li>
</ul>
<h3>Workflow timeline</h3>
<ul className="timeline">
{timeline.map((event) => (
@@ -0,0 +1,39 @@
import type { BoundaryQuestionSummary } from "@pipeline/shared";
export type BoundaryQuestionFormRow = {
id: string;
category: string;
question: string;
answer: string;
isRequired: boolean;
stateLabel: string;
};
export type BoundaryQuestionErrors = Record<string, string>;
export function buildBoundaryQuestionRows(
questions: readonly BoundaryQuestionSummary[],
): BoundaryQuestionFormRow[] {
return questions.map((question) => ({
id: question.id,
category: question.category,
question: question.question,
answer: question.answer ?? "",
isRequired: question.is_required ?? true,
stateLabel: question.is_required === false ? "Optional" : "Required",
}));
}
export function validateRequiredAnswers(
rows: readonly BoundaryQuestionFormRow[],
): BoundaryQuestionErrors {
const errors: BoundaryQuestionErrors = {};
for (const row of rows) {
if (row.isRequired && !row.answer.trim()) {
errors[row.id] = "Required answer";
}
}
return errors;
}
@@ -0,0 +1,157 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { BoundaryQuestionSummary } from "@pipeline/shared";
import { ApiError, generateBoundaryQuestions, submitBoundaryQuestions, updateBoundaryQuestion } from "@/shared/pipeline-api";
import {
buildBoundaryQuestionRows,
validateRequiredAnswers,
type BoundaryQuestionErrors,
type BoundaryQuestionFormRow,
} from "./model";
type BoundaryQuestionsEditorProps = {
articleId: string;
initialQuestions: readonly BoundaryQuestionSummary[];
};
export function BoundaryQuestionsEditor({
articleId,
initialQuestions,
}: BoundaryQuestionsEditorProps) {
const router = useRouter();
const [rows, setRows] = useState<BoundaryQuestionFormRow[]>(() =>
buildBoundaryQuestionRows(initialQuestions),
);
const [errors, setErrors] = useState<BoundaryQuestionErrors>({});
const [formMessage, setFormMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const requiredProgress = useMemo(() => {
const requiredRows = rows.filter((row) => row.isRequired);
const answeredRows = requiredRows.filter((row) => row.answer.trim());
return `${answeredRows.length}/${requiredRows.length}`;
}, [rows]);
function updateRow(questionId: string, answer: string) {
setRows((current) =>
current.map((row) => (row.id === questionId ? { ...row, answer } : row)),
);
}
async function handleGenerate() {
setBusy(true);
setFormMessage("");
try {
const response = await generateBoundaryQuestions(articleId);
setRows(buildBoundaryQuestionRows(response.questions));
setErrors({});
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to generate questions");
} finally {
setBusy(false);
}
}
async function handleSave(row: BoundaryQuestionFormRow) {
setBusy(true);
setFormMessage("");
try {
const response = await updateBoundaryQuestion(articleId, row.id, {
answer: row.answer.trim() || null,
});
setRows((current) =>
current.map((candidate) =>
candidate.id === row.id
? buildBoundaryQuestionRows([response.question])[0]
: candidate,
),
);
setErrors((current) => {
const next = { ...current };
delete next[row.id];
return next;
});
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to save answer");
} finally {
setBusy(false);
}
}
async function handleSubmit() {
const nextErrors = validateRequiredAnswers(rows);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setBusy(true);
setFormMessage("");
try {
await submitBoundaryQuestions(articleId);
router.push(`/articles/${articleId}`);
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to submit answers");
} finally {
setBusy(false);
}
}
return (
<section className="boundaryQuestions">
<div className="sectionHeader">
<div>
<h2>Boundary questions</h2>
<p>Required answers: {requiredProgress}</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
{rows.length > 0 ? "Regenerate" : "Generate"}
</button>
</div>
{formMessage ? <p className="formError">Error: {formMessage}</p> : null}
{rows.length === 0 ? (
<p className="emptyState">No boundary questions generated yet.</p>
) : (
<div className="questionList">
{rows.map((row) => (
<article className="questionItem" key={row.id}>
<div className="questionHeader">
<div>
<strong>{row.category.replaceAll("_", " ")}</strong>
<p>{row.question}</p>
</div>
<span className={row.isRequired ? "requiredBadge" : "optionalBadge"}>
{row.stateLabel}
</span>
</div>
<textarea
value={row.answer}
onChange={(event) => updateRow(row.id, event.target.value)}
/>
{errors[row.id] ? <p className="fieldError">{errors[row.id]}</p> : null}
<div className="formActions">
<button type="button" onClick={() => handleSave(row)} disabled={isBusy}>
Save
</button>
</div>
</article>
))}
</div>
)}
<div className="formActions">
<button type="button" onClick={handleSubmit} disabled={isBusy || rows.length === 0}>
Submit boundary answers
</button>
</div>
</section>
);
}
@@ -0,0 +1,34 @@
import Link from "next/link";
import { BoundaryQuestionsEditor } from "@/features/boundary-questions/ui";
import { fetchBoundaryQuestions } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticleBoundaryQuestionsPageProps = {
articleId: string;
};
export default async function ArticleBoundaryQuestionsPage({
articleId,
}: ArticleBoundaryQuestionsPageProps) {
const response = await fetchBoundaryQuestions(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Boundary questions</h1>
<p>{response.questions.length} questions</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<BoundaryQuestionsEditor
articleId={articleId}
initialQuestions={response.questions}
/>
</section>
</main>
);
}
+40
View File
@@ -6,6 +6,9 @@ import type {
AgentJobResponse,
AgentJobTestCodexRequest,
ArticleListResponse,
BoundaryQuestionListResponse,
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -285,3 +288,40 @@ export function createArticle(
request,
);
}
export function generateBoundaryQuestions(
articleId: string,
): Promise<BoundaryQuestionListResponse> {
return apiPost<unknown, BoundaryQuestionListResponse>(
`/api/articles/${articleId}/boundary-questions/generate`,
undefined,
);
}
export function fetchBoundaryQuestions(
articleId: string,
): Promise<BoundaryQuestionListResponse> {
return apiGet<BoundaryQuestionListResponse>(
`/api/articles/${articleId}/boundary-questions`,
);
}
export function updateBoundaryQuestion(
articleId: string,
questionId: string,
request: BoundaryQuestionUpdateRequest,
): Promise<BoundaryQuestionResponse> {
return apiPatch<BoundaryQuestionUpdateRequest, BoundaryQuestionResponse>(
`/api/articles/${articleId}/boundary-questions/${questionId}`,
request,
);
}
export function submitBoundaryQuestions(
articleId: string,
): Promise<ArticleCreateResponse> {
return apiPost<unknown, ArticleCreateResponse>(
`/api/articles/${articleId}/boundary-questions/submit`,
undefined,
);
}
@@ -59,5 +59,5 @@ const rows = buildDashboardRows(
assert.equal(rows.length, 1, "one row should be built");
assert.equal(rows[0].targetSiteName, "Editorial site", "target site name should map");
assert.equal(rows[0].nextAction, "Create plan", "next action should match status mapping");
assert.equal(rows[0].nextAction, "Run boundary questions", "next action should match status mapping");
assert.equal(rows[0].assignedEditor, "Unassigned", "missing editor id should render Unassigned");
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(
testDir,
"../src/features/boundary-questions/model.ts",
);
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildBoundaryQuestionRows, validateRequiredAnswers } = moduleExports;
const rows = buildBoundaryQuestionRows([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: "22222222-2222-2222-2222-222222222222",
sort_order: 1,
category: "audience",
question: "Audience?",
answer: "",
is_required: true,
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
{
id: "33333333-3333-3333-3333-333333333333",
article_id: "22222222-2222-2222-2222-222222222222",
sort_order: 2,
category: "visual_expectations",
question: "Visuals?",
answer: "",
is_required: false,
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
]);
assert.equal(rows[0].stateLabel, "Required");
assert.equal(rows[1].stateLabel, "Optional");
const errors = validateRequiredAnswers(rows);
assert.equal(errors["11111111-1111-1111-1111-111111111111"], "Required answer");
assert.equal(errors["33333333-3333-3333-3333-333333333333"], undefined);