task015 implement seo and language review flow
This commit is contained in:
@@ -348,6 +348,10 @@ main {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.reviewPanel {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.draftEditor .detailGrid select {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
@@ -382,6 +386,16 @@ main {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reviewActions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(88px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.reviewActions input {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main {
|
||||
padding: 28px;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
ContentReviewIssueSummary,
|
||||
ContentReviewReportSummary,
|
||||
ContentReviewKind,
|
||||
FinalReviewIssuesResponse,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type ReviewIssueRow = {
|
||||
reviewKind: ContentReviewKind;
|
||||
issueId: string;
|
||||
suggestionId: string;
|
||||
severity: string;
|
||||
location: string;
|
||||
message: string;
|
||||
suggestedFix: string;
|
||||
suggestedRewrite: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ReviewSummary = {
|
||||
unresolvedCount: number;
|
||||
unresolvedTitles: string[];
|
||||
};
|
||||
|
||||
export function buildReviewIssueRows(
|
||||
seoReport: ContentReviewReportSummary | null,
|
||||
languageReport: ContentReviewReportSummary | null,
|
||||
): ReviewIssueRow[] {
|
||||
return [
|
||||
...mapIssues("SEO", seoReport?.issues ?? []),
|
||||
...mapIssues("LANGUAGE", languageReport?.issues ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildFinalReviewSummary(
|
||||
response: FinalReviewIssuesResponse | null,
|
||||
): ReviewSummary {
|
||||
if (!response) {
|
||||
return { unresolvedCount: 0, unresolvedTitles: [] };
|
||||
}
|
||||
return {
|
||||
unresolvedCount: response.unresolved_count,
|
||||
unresolvedTitles: (response.unresolved_issues ?? []).map(
|
||||
(issue) => `${issue.review_kind}: ${issue.message}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mapIssues(
|
||||
reviewKind: ContentReviewKind,
|
||||
issues: readonly ContentReviewIssueSummary[],
|
||||
): ReviewIssueRow[] {
|
||||
return issues.map((issue) => ({
|
||||
reviewKind,
|
||||
issueId: issue.id,
|
||||
suggestionId: issue.suggestion_id,
|
||||
severity: issue.severity,
|
||||
location: issue.location,
|
||||
message: issue.message,
|
||||
suggestedFix: issue.suggested_fix ?? "",
|
||||
suggestedRewrite: issue.suggested_rewrite ?? "",
|
||||
status: issue.status,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
ContentReviewReportSummary,
|
||||
FinalReviewIssuesResponse,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
acceptLanguageSuggestion,
|
||||
acceptSeoSuggestion,
|
||||
editLanguageSuggestion,
|
||||
editSeoSuggestion,
|
||||
fetchFinalReviewIssues,
|
||||
fetchLanguageReport,
|
||||
fetchSeoReport,
|
||||
rejectLanguageSuggestion,
|
||||
rejectSeoSuggestion,
|
||||
runLanguageReview,
|
||||
runSeoReview,
|
||||
} from "@/shared/pipeline-api";
|
||||
import { buildFinalReviewSummary, buildReviewIssueRows } from "./model";
|
||||
|
||||
type ArticleReviewPanelProps = {
|
||||
articleId: string;
|
||||
};
|
||||
|
||||
export function ArticleReviewPanel({ articleId }: ArticleReviewPanelProps) {
|
||||
const [seoReport, setSeoReport] = useState<ContentReviewReportSummary | null>(null);
|
||||
const [languageReport, setLanguageReport] = useState<ContentReviewReportSummary | null>(null);
|
||||
const [finalIssues, setFinalIssues] = useState<FinalReviewIssuesResponse | null>(null);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
|
||||
const issueRows = useMemo(
|
||||
() => buildReviewIssueRows(seoReport, languageReport),
|
||||
[seoReport, languageReport],
|
||||
);
|
||||
const summary = useMemo(() => buildFinalReviewSummary(finalIssues), [finalIssues]);
|
||||
|
||||
async function refreshAll() {
|
||||
const [seo, language, unresolved] = await Promise.all([
|
||||
fetchSeoReport(articleId).catch(() => null),
|
||||
fetchLanguageReport(articleId).catch(() => null),
|
||||
fetchFinalReviewIssues(articleId).catch(() => null),
|
||||
]);
|
||||
setSeoReport(seo?.report ?? null);
|
||||
setLanguageReport(language?.report ?? null);
|
||||
setFinalIssues(unresolved);
|
||||
}
|
||||
|
||||
async function onRunSeo() {
|
||||
setBusyKey("seo-run");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await runSeoReview(articleId);
|
||||
setSeoReport(response.report);
|
||||
await refreshAll();
|
||||
setMessage(`SEO review completed with score ${response.report.score}.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "SEO review failed.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRunLanguage() {
|
||||
setBusyKey("language-run");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await runLanguageReview(articleId);
|
||||
setLanguageReport(response.report);
|
||||
await refreshAll();
|
||||
setMessage(`Language review completed with score ${response.report.score}.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Language review failed.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onAccept(kind: "SEO" | "LANGUAGE", suggestionId: string) {
|
||||
setBusyKey(`accept-${suggestionId}`);
|
||||
setMessage("");
|
||||
try {
|
||||
const response =
|
||||
kind === "SEO"
|
||||
? await acceptSeoSuggestion(articleId, suggestionId)
|
||||
: await acceptLanguageSuggestion(articleId, suggestionId);
|
||||
await refreshAll();
|
||||
setMessage(
|
||||
response.draft
|
||||
? `Suggestion accepted. Draft v${response.draft.version} created.`
|
||||
: "Suggestion accepted.",
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Accept failed.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onReject(kind: "SEO" | "LANGUAGE", suggestionId: string) {
|
||||
setBusyKey(`reject-${suggestionId}`);
|
||||
setMessage("");
|
||||
try {
|
||||
if (kind === "SEO") {
|
||||
await rejectSeoSuggestion(articleId, suggestionId);
|
||||
} else {
|
||||
await rejectLanguageSuggestion(articleId, suggestionId);
|
||||
}
|
||||
await refreshAll();
|
||||
setMessage("Suggestion rejected.");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Reject failed.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onEdit(kind: "SEO" | "LANGUAGE", suggestionId: string) {
|
||||
const appliedText = (edits[suggestionId] ?? "").trim();
|
||||
if (!appliedText) {
|
||||
setMessage("Provide edited text before applying.");
|
||||
return;
|
||||
}
|
||||
setBusyKey(`edit-${suggestionId}`);
|
||||
setMessage("");
|
||||
try {
|
||||
const response =
|
||||
kind === "SEO"
|
||||
? await editSeoSuggestion(articleId, suggestionId, { applied_text: appliedText })
|
||||
: await editLanguageSuggestion(articleId, suggestionId, { applied_text: appliedText });
|
||||
await refreshAll();
|
||||
setMessage(
|
||||
response.draft
|
||||
? `Suggestion edited. Draft v${response.draft.version} created.`
|
||||
: "Suggestion edited.",
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof ApiError ? error.message : "Edit failed.");
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="reviewPanel">
|
||||
<div className="sectionHeader">
|
||||
<div>
|
||||
<h2>SEO and language review</h2>
|
||||
<p>Unresolved issues: {summary.unresolvedCount}</p>
|
||||
</div>
|
||||
<div className="formActions">
|
||||
<button type="button" onClick={onRunSeo} disabled={busyKey !== null}>
|
||||
{busyKey === "seo-run" ? "Running..." : "Run SEO review"}
|
||||
</button>
|
||||
<button type="button" onClick={onRunLanguage} disabled={busyKey !== null}>
|
||||
{busyKey === "language-run" ? "Running..." : "Run language review"}
|
||||
</button>
|
||||
<button type="button" onClick={() => void refreshAll()} disabled={busyKey !== null}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
|
||||
|
||||
{summary.unresolvedTitles.length > 0 ? (
|
||||
<ul className="timeline">
|
||||
{summary.unresolvedTitles.map((title) => (
|
||||
<li key={title}>{title}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th>Severity</th>
|
||||
<th>Location</th>
|
||||
<th>Message</th>
|
||||
<th>Suggestion</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{issueRows.map((row) => (
|
||||
<tr key={`${row.reviewKind}-${row.suggestionId}`}>
|
||||
<td>{row.reviewKind}</td>
|
||||
<td>{row.severity}</td>
|
||||
<td>{row.location}</td>
|
||||
<td>{row.message}</td>
|
||||
<td>{row.suggestedRewrite || row.suggestedFix || "—"}</td>
|
||||
<td>{row.status}</td>
|
||||
<td>
|
||||
<div className="reviewActions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onAccept(row.reviewKind, row.suggestionId)}
|
||||
disabled={busyKey !== null}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReject(row.reviewKind, row.suggestionId)}
|
||||
disabled={busyKey !== null}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<input
|
||||
value={edits[row.suggestionId] ?? row.suggestedRewrite}
|
||||
onChange={(event) =>
|
||||
setEdits((current) => ({
|
||||
...current,
|
||||
[row.suggestionId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onEdit(row.reviewKind, row.suggestionId)}
|
||||
disabled={busyKey !== null}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{issueRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7}>No review issues yet.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/features/article-detail/model";
|
||||
import { ArticleDetailShell } from "@/features/article-detail/ui";
|
||||
import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { ApiError } from "@/shared/pipeline-api";
|
||||
@@ -42,6 +43,7 @@ export default async function ArticleDetailPage({
|
||||
initialDrafts={draftsResponse.drafts ?? []}
|
||||
assets={detail.assets ?? []}
|
||||
/>
|
||||
<ArticleReviewPanel articleId={articleId} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -23,13 +23,20 @@ import type {
|
||||
EvidenceResponse,
|
||||
EvidenceCreateRequest,
|
||||
EvidenceUpdateRequest,
|
||||
FinalReviewIssuesResponse,
|
||||
LanguageReviewReportResponse,
|
||||
LanguageReviewRunResponse,
|
||||
PlanListResponse,
|
||||
PlanResponse,
|
||||
PlanRevisionRequest,
|
||||
PlanUpdateRequest,
|
||||
ResearchListResponse,
|
||||
ResearchStartResponse,
|
||||
ReviewSuggestionActionResponse,
|
||||
ReviewSuggestionEditRequest,
|
||||
ReviewActionResponse,
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
@@ -523,3 +530,95 @@ export function uploadAssetFile(
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function runSeoReview(articleId: string): Promise<SeoReviewRunResponse> {
|
||||
return apiPost<unknown, SeoReviewRunResponse>(
|
||||
`/api/articles/${articleId}/seo/review`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchSeoReport(articleId: string): Promise<SeoReviewReportResponse> {
|
||||
return apiGet<SeoReviewReportResponse>(`/api/articles/${articleId}/seo/report`);
|
||||
}
|
||||
|
||||
export function runLanguageReview(articleId: string): Promise<LanguageReviewRunResponse> {
|
||||
return apiPost<unknown, LanguageReviewRunResponse>(
|
||||
`/api/articles/${articleId}/language/review`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchLanguageReport(
|
||||
articleId: string,
|
||||
): Promise<LanguageReviewReportResponse> {
|
||||
return apiGet<LanguageReviewReportResponse>(`/api/articles/${articleId}/language/report`);
|
||||
}
|
||||
|
||||
export function acceptSeoSuggestion(
|
||||
articleId: string,
|
||||
suggestionId: string,
|
||||
): Promise<ReviewSuggestionActionResponse> {
|
||||
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/accept`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function rejectSeoSuggestion(
|
||||
articleId: string,
|
||||
suggestionId: string,
|
||||
): Promise<ReviewSuggestionActionResponse> {
|
||||
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/reject`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function editSeoSuggestion(
|
||||
articleId: string,
|
||||
suggestionId: string,
|
||||
request: ReviewSuggestionEditRequest,
|
||||
): Promise<ReviewSuggestionActionResponse> {
|
||||
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
|
||||
`/api/articles/${articleId}/seo/suggestions/${suggestionId}/edit`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptLanguageSuggestion(
|
||||
articleId: string,
|
||||
suggestionId: string,
|
||||
): Promise<ReviewSuggestionActionResponse> {
|
||||
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||
`/api/articles/${articleId}/language/suggestions/${suggestionId}/accept`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function rejectLanguageSuggestion(
|
||||
articleId: string,
|
||||
suggestionId: string,
|
||||
): Promise<ReviewSuggestionActionResponse> {
|
||||
return apiPost<unknown, ReviewSuggestionActionResponse>(
|
||||
`/api/articles/${articleId}/language/suggestions/${suggestionId}/reject`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function editLanguageSuggestion(
|
||||
articleId: string,
|
||||
suggestionId: string,
|
||||
request: ReviewSuggestionEditRequest,
|
||||
): Promise<ReviewSuggestionActionResponse> {
|
||||
return apiPost<ReviewSuggestionEditRequest, ReviewSuggestionActionResponse>(
|
||||
`/api/articles/${articleId}/language/suggestions/${suggestionId}/edit`,
|
||||
request,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchFinalReviewIssues(
|
||||
articleId: string,
|
||||
): Promise<FinalReviewIssuesResponse> {
|
||||
return apiGet<FinalReviewIssuesResponse>(`/api/articles/${articleId}/final-review/issues`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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/article-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 { buildReviewIssueRows, buildFinalReviewSummary } = moduleExports;
|
||||
|
||||
const seoReport = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
review_kind: "SEO",
|
||||
draft_id: "dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
score: 72,
|
||||
issues: [
|
||||
{
|
||||
id: "meta-description-length",
|
||||
suggestion_id: "22222222-2222-2222-2222-222222222222",
|
||||
severity: "HIGH",
|
||||
location: "meta_description",
|
||||
message: "Meta description too short.",
|
||||
suggested_fix: "Use 120-160 chars.",
|
||||
suggested_rewrite: "Expanded meta description text.",
|
||||
status: "PENDING",
|
||||
},
|
||||
],
|
||||
recommended_slug: "seo-language-review-guide",
|
||||
recommended_title: "SEO Language Review Guide",
|
||||
schema_json: { "@type": "Article" },
|
||||
rules_snapshot: { meta_description_min: 120 },
|
||||
created_at: "2026-05-22T12:00:00Z",
|
||||
};
|
||||
|
||||
const languageReport = {
|
||||
...seoReport,
|
||||
id: "33333333-3333-3333-3333-333333333333",
|
||||
review_kind: "LANGUAGE",
|
||||
issues: [
|
||||
{
|
||||
id: "long-sentence",
|
||||
suggestion_id: "44444444-4444-4444-4444-444444444444",
|
||||
severity: "MEDIUM",
|
||||
location: "body_markdown",
|
||||
message: "Sentence exceeds 24 words.",
|
||||
suggested_fix: "Split sentence.",
|
||||
suggested_rewrite: "This sentence is shorter.",
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const rows = buildReviewIssueRows(seoReport, languageReport);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].reviewKind, "SEO");
|
||||
assert.equal(rows[0].suggestionId, "22222222-2222-2222-2222-222222222222");
|
||||
assert.equal(rows[1].reviewKind, "LANGUAGE");
|
||||
assert.equal(rows[1].status, "ACCEPTED");
|
||||
|
||||
const summary = buildFinalReviewSummary({
|
||||
article_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
unresolved_count: 2,
|
||||
unresolved_issues: [
|
||||
{
|
||||
review_kind: "SEO",
|
||||
suggestion_id: "22222222-2222-2222-2222-222222222222",
|
||||
severity: "HIGH",
|
||||
location: "meta_description",
|
||||
message: "Meta description too short.",
|
||||
status: "PENDING",
|
||||
},
|
||||
{
|
||||
review_kind: "LANGUAGE",
|
||||
suggestion_id: "55555555-5555-5555-5555-555555555555",
|
||||
severity: "MEDIUM",
|
||||
location: "body_markdown",
|
||||
message: "Sentence exceeds 24 words.",
|
||||
status: "PENDING",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(summary.unresolvedCount, 2);
|
||||
assert.equal(summary.unresolvedTitles.length, 2);
|
||||
assert.ok(summary.unresolvedTitles[0].startsWith("SEO:"));
|
||||
Reference in New Issue
Block a user