task015 implement seo and language review flow

This commit is contained in:
2026-05-22 00:16:08 +03:00
parent 8243f20b0e
commit 6dcbe0e36c
20 changed files with 3828 additions and 14 deletions
+14
View File
@@ -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>
);
+99
View File
@@ -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:"));