Task 009 implement plan generation review gate

This commit is contained in:
2026-05-21 19:56:58 +03:00
parent b7e2fa34b2
commit 5b27d1a3ca
24 changed files with 2422 additions and 59 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 && node tests/boundary_questions.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 && node tests/plan_review.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,14 @@
import ArticlePlansPage from "@/pages/article-plans";
type DynamicArticlePlansPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticlePlansPage({
params,
}: DynamicArticlePlansPageProps) {
const { articleId } = params;
return <ArticlePlansPage articleId={articleId} />;
}
@@ -44,6 +44,9 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
Boundary questions
</Link>
</li>
<li>
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
</li>
</ul>
<h3>Workflow timeline</h3>
@@ -0,0 +1,37 @@
import type { PlanSummary } from "@pipeline/shared";
export type PlanReviewView = {
id: string;
version: number;
status: string;
recommendedTitle: string;
readerPersona: string;
searchIntent: string;
sectionCount: number;
evidenceNeeds: string;
visualNeeds: string;
seoNotes: string;
sourceRequirements: string;
excludedSources: string;
tone: string;
audience: string;
};
export function buildPlanReviewViews(plans: readonly PlanSummary[]): PlanReviewView[] {
return plans.map((plan) => ({
id: plan.id,
version: plan.version,
status: plan.status,
recommendedTitle: plan.recommended_title ?? "",
readerPersona: plan.reader_persona ?? "",
searchIntent: plan.search_intent ?? "",
sectionCount: plan.sections?.length ?? 0,
evidenceNeeds: (plan.evidence_needs ?? []).join(", "),
visualNeeds: (plan.visual_needs ?? []).join(", "),
seoNotes: (plan.seo_notes ?? []).join(", "),
sourceRequirements: (plan.source_requirements ?? []).join(", "),
excludedSources: (plan.excluded_sources ?? []).join(", "),
tone: plan.tone ?? "",
audience: plan.audience ?? "",
}));
}
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { PlanSummary } from "@pipeline/shared";
import {
ApiError,
approveArticlePlan,
generateArticlePlan,
requestArticlePlanRevision,
updateArticlePlan,
} from "@/shared/pipeline-api";
import { buildPlanReviewViews } from "./model";
type PlanReviewPanelProps = {
articleId: string;
initialPlans: readonly PlanSummary[];
};
export function PlanReviewPanel({ articleId, initialPlans }: PlanReviewPanelProps) {
const router = useRouter();
const [plans, setPlans] = useState<PlanSummary[]>([...initialPlans]);
const [message, setMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const views = buildPlanReviewViews(plans);
const currentPlan = plans.at(-1) ?? null;
async function runAction(action: () => Promise<unknown>) {
setBusy(true);
setMessage("");
try {
await action();
router.refresh();
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Plan action failed");
} finally {
setBusy(false);
}
}
function handleGenerate() {
void runAction(async () => {
const response = await generateArticlePlan(articleId);
setPlans((current) => [...current, response.plan]);
});
}
function handleEdit() {
if (!currentPlan) {
return;
}
void runAction(async () => {
const response = await updateArticlePlan(articleId, currentPlan.id, {
recommended_title: `${currentPlan.recommended_title ?? "Plan"} edited`,
tone: currentPlan.tone ?? "Evidence-led",
audience: currentPlan.audience ?? currentPlan.reader_persona,
seo_notes: [...(currentPlan.seo_notes ?? []), "Editor adjustment"],
source_requirements: currentPlan.source_requirements ?? [],
excluded_sources: currentPlan.excluded_sources ?? [],
visual_needs: currentPlan.visual_needs ?? [],
});
setPlans((current) => [...current, response.plan]);
});
}
function handleRevision() {
if (!currentPlan) {
return;
}
void runAction(() =>
requestArticlePlanRevision(articleId, currentPlan.id, {
notes: "Revise plan scope and evidence standard.",
}),
);
}
function handleApprove() {
if (!currentPlan) {
return;
}
void runAction(() => approveArticlePlan(articleId, currentPlan.id));
}
return (
<section>
<div className="sectionHeader">
<div>
<h2>Plan review</h2>
<p>{plans.length} versions</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
Generate
</button>
</div>
{message ? <p className="formError">Error: {message}</p> : null}
{views.length === 0 ? (
<p className="emptyState">No plans generated yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Version</th>
<th>Plan</th>
<th>Requirements</th>
<th>Controls</th>
</tr>
</thead>
<tbody>
{views.map((view) => (
<tr key={view.id}>
<td>
<strong>v{view.version}</strong>
<div>{view.status}</div>
</td>
<td>
<div>{view.recommendedTitle}</div>
<div>{view.readerPersona}</div>
<div>{view.searchIntent}</div>
<div>{view.sectionCount} sections</div>
</td>
<td>
<div>Sources: {view.sourceRequirements}</div>
<div>Excluded: {view.excludedSources}</div>
<div>Visuals: {view.visualNeeds}</div>
<div>SEO: {view.seoNotes}</div>
</td>
<td>
<div>Tone: {view.tone}</div>
<div>Audience: {view.audience}</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="formActions">
<button type="button" onClick={handleEdit} disabled={isBusy || !currentPlan}>
Edit
</button>
<button type="button" onClick={handleRevision} disabled={isBusy || !currentPlan}>
Request revision
</button>
<button type="button" onClick={handleApprove} disabled={isBusy || !currentPlan}>
Approve
</button>
</div>
</section>
);
}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { PlanReviewPanel } from "@/features/plan-review/ui";
import { fetchArticlePlans } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticlePlansPageProps = {
articleId: string;
};
export default async function ArticlePlansPage({ articleId }: ArticlePlansPageProps) {
const response = await fetchArticlePlans(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Plan review</h1>
<p>{response.plans.length} versions</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<PlanReviewPanel articleId={articleId} initialPlans={response.plans} />
</section>
</main>
);
}
+48
View File
@@ -10,6 +10,11 @@ import type {
BoundaryQuestionResponse,
BoundaryQuestionUpdateRequest,
CurrentUserResponse,
PlanListResponse,
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ReviewActionResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
ScriptConfigVersionResponse,
@@ -325,3 +330,46 @@ export function submitBoundaryQuestions(
undefined,
);
}
export function generateArticlePlan(articleId: string): Promise<PlanResponse> {
return apiPost<unknown, PlanResponse>(
`/api/articles/${articleId}/plan/generate`,
undefined,
);
}
export function fetchArticlePlans(articleId: string): Promise<PlanListResponse> {
return apiGet<PlanListResponse>(`/api/articles/${articleId}/plans`);
}
export function updateArticlePlan(
articleId: string,
planId: string,
request: PlanUpdateRequest,
): Promise<PlanResponse> {
return apiPatch<PlanUpdateRequest, PlanResponse>(
`/api/articles/${articleId}/plans/${planId}`,
request,
);
}
export function approveArticlePlan(
articleId: string,
planId: string,
): Promise<ReviewActionResponse> {
return apiPost<unknown, ReviewActionResponse>(
`/api/articles/${articleId}/plans/${planId}/approve`,
undefined,
);
}
export function requestArticlePlanRevision(
articleId: string,
planId: string,
request: PlanRevisionRequest,
): Promise<ReviewActionResponse> {
return apiPost<PlanRevisionRequest, ReviewActionResponse>(
`/api/articles/${articleId}/plans/${planId}/request-revision`,
request,
);
}
@@ -0,0 +1,50 @@
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/plan-review/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 { buildPlanReviewViews } = moduleExports;
const rows = buildPlanReviewViews([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: "22222222-2222-2222-2222-222222222222",
version: 2,
status: "PENDING_REVIEW",
title_options: ["A", "B"],
recommended_title: "Recommended",
reader_persona: "Editors",
search_intent: "Compare",
thesis: "Thesis",
sections: [{ id: "s", article_plan_id: "p", sort_order: 1, heading: "H" }],
claims_to_prove: [],
evidence_needs: ["Docs"],
visual_needs: ["Diagram"],
seo_notes: ["Keyword"],
source_requirements: ["Primary"],
excluded_sources: ["Forums"],
tone: "Direct",
audience: "Technical editors",
risks: [],
created_at: "2026-05-21T00:00:00Z",
},
]);
assert.equal(rows[0].version, 2);
assert.equal(rows[0].recommendedTitle, "Recommended");
assert.equal(rows[0].sectionCount, 1);
assert.equal(rows[0].sourceRequirements, "Primary");
assert.equal(rows[0].excludedSources, "Forums");
assert.equal(rows[0].visualNeeds, "Diagram");