Task 010 implement research manifest flow

This commit is contained in:
2026-05-21 21:56:44 +03:00
parent 5b27d1a3ca
commit 7d4a79acb5
23 changed files with 1063 additions and 26 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 && node tests/plan_review.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 && node tests/research_manifest.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,14 @@
import ArticleResearchPage from "@/pages/article-research";
type DynamicArticleResearchPageProps = {
params: {
articleId: string;
};
};
export default async function DynamicArticleResearchPage({
params,
}: DynamicArticleResearchPageProps) {
const { articleId } = params;
return <ArticleResearchPage articleId={articleId} />;
}
@@ -47,6 +47,9 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
<li>
<Link href={`/articles/${summary.articleId}/plans`}>Plan review</Link>
</li>
<li>
<Link href={`/articles/${summary.articleId}/research`}>Research</Link>
</li>
</ul>
<h3>Workflow timeline</h3>
@@ -0,0 +1,40 @@
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
export type ResearchSourceRow = {
manifestId: string;
sourceUrl: string;
title: string;
domain: string;
type: string;
summary: string;
retrievedAt: string;
artifactLink: string;
objectKey: string;
contentHash: string;
};
function stringValue(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function buildResearchSourceRows(
manifests: readonly ResearchArtifactManifestSummary[],
): ResearchSourceRow[] {
return manifests.flatMap((manifest) =>
(manifest.artifacts ?? []).map((artifact) => {
const metadata = artifact.metadata ?? {};
return {
manifestId: manifest.id,
sourceUrl: artifact.source_url,
title: stringValue(metadata.source_title),
domain: stringValue(metadata.domain),
type: stringValue(metadata.source_type) || artifact.artifact_type,
summary: stringValue(metadata.summary),
retrievedAt: stringValue(metadata.retrieved_at) || manifest.created_at,
artifactLink: stringValue(metadata.artifact_link) || artifact.object_key,
objectKey: artifact.object_key,
contentHash: artifact.content_hash,
};
}),
);
}
@@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { ResearchArtifactManifestSummary } from "@pipeline/shared";
import { ApiError, startResearch } from "@/shared/pipeline-api";
import { buildResearchSourceRows } from "./model";
type ResearchManifestPanelProps = {
articleId: string;
initialManifests: readonly ResearchArtifactManifestSummary[];
};
export function ResearchManifestPanel({
articleId,
initialManifests,
}: ResearchManifestPanelProps) {
const router = useRouter();
const [manifests, setManifests] = useState<ResearchArtifactManifestSummary[]>([
...initialManifests,
]);
const [message, setMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const rows = buildResearchSourceRows(manifests);
async function handleStart() {
setBusy(true);
setMessage("");
try {
const response = await startResearch(articleId);
setManifests((current) => [...current, response.manifest]);
router.refresh();
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Unable to start research");
} finally {
setBusy(false);
}
}
return (
<section>
<div className="sectionHeader">
<div>
<h2>Research</h2>
<p>{manifests.length} runs</p>
</div>
<button type="button" onClick={handleStart} disabled={isBusy}>
Start research
</button>
</div>
{message ? <p className="formError">Error: {message}</p> : null}
{rows.length === 0 ? (
<p className="emptyState">No research sources yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Source</th>
<th>Summary</th>
<th>Retrieved</th>
<th>Artifact</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.manifestId}-${row.objectKey}`}>
<td>
<div>{row.title}</div>
<a href={row.sourceUrl}>{row.domain || row.sourceUrl}</a>
<div>{row.type}</div>
</td>
<td>{row.summary}</td>
<td>{new Date(row.retrievedAt).toLocaleString()}</td>
<td>
<div>{row.artifactLink}</div>
<small>{row.contentHash}</small>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
@@ -0,0 +1,34 @@
import Link from "next/link";
import { ResearchManifestPanel } from "@/features/research-manifest/ui";
import { fetchResearch } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type ArticleResearchPageProps = {
articleId: string;
};
export default async function ArticleResearchPage({
articleId,
}: ArticleResearchPageProps) {
const response = await fetchResearch(articleId);
return (
<main>
<header className="pageHeader">
<div>
<h1>Research</h1>
<p>{response.manifests?.length ?? 0} runs</p>
</div>
<Link href={`/articles/${articleId}`}>Back to article</Link>
</header>
<RoleNavigation role="EDITOR" />
<section className="panel">
<ResearchManifestPanel
articleId={articleId}
initialManifests={response.manifests ?? []}
/>
</section>
</main>
);
}
+13
View File
@@ -14,6 +14,8 @@ import type {
PlanResponse,
PlanRevisionRequest,
PlanUpdateRequest,
ResearchListResponse,
ResearchStartResponse,
ReviewActionResponse,
ScriptConfigVersionCreateRequest,
ScriptConfigVersionListResponse,
@@ -373,3 +375,14 @@ export function requestArticlePlanRevision(
request,
);
}
export function startResearch(articleId: string): Promise<ResearchStartResponse> {
return apiPost<unknown, ResearchStartResponse>(
`/api/articles/${articleId}/research/start`,
undefined,
);
}
export function fetchResearch(articleId: string): Promise<ResearchListResponse> {
return apiGet<ResearchListResponse>(`/api/articles/${articleId}/research`);
}
@@ -0,0 +1,55 @@
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/research-manifest/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 { buildResearchSourceRows } = moduleExports;
const rows = buildResearchSourceRows([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: "22222222-2222-2222-2222-222222222222",
agent_job_id: "33333333-3333-3333-3333-333333333333",
s3_prefix: "research/article/run",
created_at: "2026-05-21T00:00:00Z",
artifacts: [
{
artifact_type: "source_section_snapshot",
source_url: "https://example.com/source",
object_key: "research/article/run/source-1/section.json",
content_hash: "abc123",
metadata: {
source_title: "Source title",
domain: "example.com",
source_type: "primary_reference",
summary: "Useful source summary",
retrieved_at: "2026-05-21T00:00:01Z",
artifact_link: "research/article/run/source-1/section.json",
},
},
],
},
]);
assert.equal(rows.length, 1);
assert.equal(rows[0].sourceUrl, "https://example.com/source");
assert.equal(rows[0].title, "Source title");
assert.equal(rows[0].domain, "example.com");
assert.equal(rows[0].type, "primary_reference");
assert.equal(rows[0].summary, "Useful source summary");
assert.equal(rows[0].artifactLink, "research/article/run/source-1/section.json");