Task 010 implement research manifest flow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user