openvibers/openvibe.news Commits

Review queue: AI briefs and stories waiting for a person; reviewed briefs indexable (#209)

OP OpenVibers committed 7 days ago · 388ac0d861 · 9 files, +263 −12

* Review queue: AI briefs and stories waiting for a person; reviewed briefs indexable

* Say when a reviewed AI brief is indexable

Changes

ddiff --git a/README.md b/README.md
iindex 70a9077..673cf70 100644
---- a/README.md
++++ b/README.md
@@@ -43,7 +43,7 @@ view. Stories stay editor-written (below); beside them News now has:
minutes an address, 40 an hour in all), never by a link a crawler follows.
- **Live** (`/live`): the newest reports as they arrive, grouped, refreshing itself every minute (`Refresh: 60`), with
**Breaking** on a subject three or more reports covered in the last two hours, and links to their AI briefs.
--- Briefs and Live are `noindex`: they are AI text and other publishers' headlines, not News' writing.
++- Briefs and Live are `noindex` by default: they are AI text and other publishers' headlines, not News' writing. A person can review a brief (`POST /api/v1/briefs/:id/reviews`, the `/admin/review` queue); a reviewed summary brief with enough independent outlets becomes indexable and is listed in `/sitemaps/briefs.xml`. A remake after its cluster changes waits for a person again.
`NEWS_AI_BRIEFS=off` turns briefs off.
## Owns
ddiff --git a/migrations/0006_brief_reviews.sql b/migrations/0006_brief_reviews.sql
nnew file mode 100644
iindex 0000000..a4b14d5
---- /dev/null
++++ b/migrations/0006_brief_reviews.sql
@@@ -0,0 +1,26 @@
++-- phase: expand
++-- AI briefs are published only after a person reviews them (server/domain/briefs.js, the reviewer queue).
++-- Append-only, like news_story_reviews: a review names the person, the decision and the brief it reviewed.
++-- brief_created_at is part of the review: when the sweep remakes a brief after its cluster changes, the
++-- old approval no longer applies and the page waits for a person again.
++CREATE TABLE IF NOT EXISTS news_brief_reviews (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ cluster_id text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('summary', 'perspectives')),
++ tone text COLLATE "C" NOT NULL CHECK (tone IN ('brief', 'neutral', 'explained')),
++ brief_created_at bigint NOT NULL,
++ reviewer text NOT NULL,
++ decision text NOT NULL CHECK (decision IN ('approved', 'rejected')),
++ note text,
++ reviewed_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS news_brief_reviews_latest ON news_brief_reviews (cluster_id, kind, tone, id);
++CREATE OR REPLACE FUNCTION news_brief_reviews_guard() RETURNS trigger AS $$
++BEGIN
++ RAISE EXCEPTION 'news_brief_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
++END;
++$$ LANGUAGE plpgsql;
++DROP TRIGGER IF EXISTS news_brief_reviews_no_update ON news_brief_reviews;
++CREATE TRIGGER news_brief_reviews_no_update BEFORE UPDATE ON news_brief_reviews FOR EACH ROW EXECUTE FUNCTION news_brief_reviews_guard();
++DROP TRIGGER IF EXISTS news_brief_reviews_no_delete ON news_brief_reviews;
++CREATE TRIGGER news_brief_reviews_no_delete BEFORE DELETE ON news_brief_reviews FOR EACH ROW EXECUTE FUNCTION news_brief_reviews_guard();
ddiff --git a/server/domain/briefs.js b/server/domain/briefs.js
iindex 6066edb..9e3995b 100644
---- a/server/domain/briefs.js
++++ b/server/domain/briefs.js
@@@ -43,6 +43,15 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
ON CONFLICT (cluster_id, kind, tone) DO UPDATE SET headline = excluded.headline, paragraphs = excluded.paragraphs, source_ids = excluded.source_ids,
gaps = excluded.gaps, run_id = excluded.run_id, model = excluded.model, stub = excluded.stub, item_count = excluded.item_count,
cluster_updated_at = excluded.cluster_updated_at, created_at = excluded.created_at`),
++ reviewLatest: db.prepare('SELECT * FROM news_brief_reviews WHERE cluster_id = ? AND kind = ? AND tone = ? ORDER BY id DESC LIMIT 1'),
++ reviewInsert: db.prepare(`INSERT INTO news_brief_reviews (cluster_id, kind, tone, brief_created_at, reviewer, decision, note, reviewed_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`),
++ pending: db.prepare(`SELECT b.*, c.label, c.window_end, c.updated_at AS cluster_updated_at_now,
++ (SELECT count(DISTINCT i.outlet) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS outlets,
++ (SELECT count(*) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS item_count_now
++ FROM news_cluster_briefs b JOIN news_story_clusters c ON c.id = b.cluster_id
++ WHERE b.kind = 'summary' AND b.tone = 'neutral' AND b.stub = false AND c.status = 'open'
++ ORDER BY b.created_at, b.cluster_id LIMIT 1000`),
latest: db.prepare(`SELECT * FROM (SELECT b.*, c.label, c.window_end,
(SELECT count(DISTINCT i.outlet) FROM news_source_items i WHERE i.cluster_id = c.id AND i.status = 'active' AND i.removed_at IS NULL) AS outlets
FROM news_cluster_briefs b JOIN news_story_clusters c ON c.id = b.cluster_id
@@@ -74,6 +83,69 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
};
}
++ const reviewShape = (r) => (r ? { id: r.id, clusterId: r.cluster_id, kind: r.kind, tone: r.tone, briefCreatedAt: Number(r.brief_created_at), reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: Number(r.reviewed_at) } : null);
++
++ /** The latest review of one brief (any decision), or null. */
++ async function reviewOf(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {
++ return reviewShape(await q.reviewLatest.get(clusterId, kind, tone));
++ }
++
++ /** An approval counts only for the exact brief it reviewed (a remake changes created_at). */
++ function isApproved(brief, review) {
++ return Boolean(brief && review && review.decision === 'approved' && Number(review.briefCreatedAt) === Number(brief.created_at));
++ }
++
++ /** A person's review of a brief. Only a person (usr_…) can review; the review log is the audit. */
++ async function review(viewer, clusterId, { kind = 'summary', tone = 'neutral', decision, note = null } = {}) {
++ if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw Object.assign(new Error('Only a signed-in person can review a brief'), { code: 'review.person_required', status: 403 });
++ if (!KINDS.includes(kind) || !TONES.includes(tone)) throw Object.assign(new Error('kind or tone not known'), { code: 'brief.invalid', status: 422 });
++ if (decision !== 'approved' && decision !== 'rejected') throw Object.assign(new Error('decision is approved or rejected'), { code: 'review.invalid_decision', status: 422 });
++ const got = await get(clusterId, { kind, tone });
++ if (!got || !got.brief) throw Object.assign(new Error('No such brief'), { code: 'brief.not_found', status: 404 });
++ if (got.brief.stub || !got.enough) throw Object.assign(new Error('This brief is not shown to readers'), { code: 'brief.not_public', status: 422 });
++ const row = await q.reviewInsert.get(String(clusterId), kind, tone, Number(got.brief.created_at), viewer.subject, decision, note == null ? null : String(note).slice(0, 2000), store.now());
++ return reviewShape(row);
++ }
++
++ /** Every current brief nobody has reviewed yet, oldest first: what the reviewer queue lists. */
++ async function pendingReviews() {
++ const items = [];
++ for (const row of await q.pending.all()) {
++ if (Number(row.outlets) < MIN_OUTLETS) continue;
++ if (Number(row.item_count) !== Number(row.item_count_now) || Number(row.cluster_updated_at) !== Number(row.cluster_updated_at_now)) continue;
++ const brief = shape(row);
++ const review = await reviewOf(row.cluster_id);
++ if (review && Number(review.briefCreatedAt) === Number(brief.created_at)) continue; // a decision for this exact text
++ items.push({
++ site: 'news', kind: 'ai_brief', id: row.cluster_id,
++ title: brief.headline || row.label, revision: null,
++ url: `/briefs/${row.cluster_id}`,
++ createdAt: new Date(brief.created_at).toISOString(),
++ reasons: [
++ { code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'a person has not reviewed this brief', text: 'AI-written and not reviewed by a person.' },
++ { code: 'noindex_requested', effect: 'noindex', detail: 'briefs are noindex until reviewed', text: 'An editor asked for it to stay out of search engines until a person reviews it.' },
++ ],
++ review: { method: 'POST', path: `/api/v1/briefs/${encodeURIComponent(row.cluster_id)}/reviews` },
++ });
++ }
++ return items;
++ }
++
++ /** Reviewed briefs that still match their review, newest review first, for the sitemap. */
++ async function reviewed(limit = 1000) {
++ const out = [];
++ for (const row of await q.pending.all()) {
++ if (Number(row.outlets) < MIN_OUTLETS) continue;
++ if (Number(row.item_count) !== Number(row.item_count_now) || Number(row.cluster_updated_at) !== Number(row.cluster_updated_at_now)) continue;
++ const brief = shape(row);
++ const review = await reviewOf(row.cluster_id);
++ if (!isApproved(brief, review)) continue;
++ out.push({ ...brief, label: row.label, window_end: Number(row.window_end), review });
++ if (out.length >= limit) break;
++ }
++ return out.sort((a, b) => b.review.reviewedAt - a.review.reviewedAt);
++ }
++
/** Make (or remake) one brief. Throws the AI client's error when the run fails; makes nothing without cited text. */
async function make(clusterId, { kind = 'summary', tone = 'neutral' } = {}) {
if (!ai || !ai.enabled) throw Object.assign(new Error('OpenVibe.AI is not configured on this server'), { code: 'ai.not_configured', status: 503 });
@@@ -119,7 +191,7 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
const latest = async (limit = 12) => (await q.latest.all(MIN_OUTLETS, limit)).map((r) => ({ ...shape(r), label: r.label, window_end: Number(r.window_end), outlets: Number(r.outlets) }));
-- return { TONES, TONE_TEXT, KINDS, MIN_REPORTS, MIN_OUTLETS, get, make, sweep, latest };
++ return { TONES, TONE_TEXT, KINDS, MIN_REPORTS, MIN_OUTLETS, get, make, sweep, latest, review, reviewOf, isApproved, pendingReviews, reviewed };
}
module.exports = { createBriefs, TONES, TONE_TEXT, MIN_REPORTS };
ddiff --git a/server/domain/stories.js b/server/domain/stories.js
iindex 03dd841..8ac6d54 100644
---- a/server/domain/stories.js
++++ b/server/domain/stories.js
@@@ -23,6 +23,7 @@
const { ids } = require('openvibe-contracts');
const { slugify } = require('openvibe-publishing/taxonomy');
const authorship = require('openvibe-publishing/authorship');
++const seo = require('openvibe-publishing/seo');
const { ApiError } = require('../http/errors');
const { actorRef, canPublish } = require('./publication');
const access = require('./access');
@@@ -563,6 +564,36 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
return await flagRow(story, { kind, note: n, createdBy: subject });
},
++ /**
++ * Stories whose head (or published) revision is AI-written and has no review yet: what a
++ * person still has to review before the story can be published or indexed. Editor-only,
++ * oldest first, with the gate's reasons and the review path.
++ */
++ async pendingReviews() {
++ const rows = await db.prepare(`SELECT * FROM news_stories WHERE state IN ('draft', 'published') ORDER BY created_at, id LIMIT 1000`).all();
++ const items = [];
++ for (const story of rows) {
++ const head = await store.revisions.head(story.id);
++ const rev = story.state === 'published' && story.published_revision ? await store.revisions.get(story.id, story.published_revision) : head;
++ const rec = rev && rev.meta && rev.meta.authorship;
++ if (!rec || !(rec.mode === 'ai' || rec.mode === 'hybrid' || rec.stubProvider)) continue;
++ const review = await store.reviews.latest(story.id, rev.number);
++ if (review) continue;
++ const decision = await publication.decide(story, rev, { state: story.state });
++ items.push({
++ site: 'news', kind: 'news_story', id: story.id,
++ title: rev.fields.headline || story.working_headline || story.slug,
++ revision: rev.number, state: story.state,
++ url: publication.storyUrl(story), editUrl: `/edit/stories/${story.id}`,
++ createdAt: new Date(rev.createdAt).toISOString(),
++ reasons: decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
++ review: { method: 'POST', path: `/api/v1/stories/${encodeURIComponent(story.id)}/reviews` },
++ ...(story.state !== 'published' ? { publish: { method: 'POST', path: `/api/v1/stories/${encodeURIComponent(story.id)}/publish` } } : {}),
++ });
++ }
++ return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
++ },
++
/** A person's review of one revision (what lets an AI draft be published and indexed). */
async review(viewer, story, { revision, decision, note } = {}, { traceparent } = {}) {
if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex db93173..cfcd2f1 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -18,6 +18,8 @@
* POST /stories/:id/unpublish news.story.publish
* POST /stories/:id/retract news.story.retract { note }
* POST /stories/:id/reviews people only { revision, decision, note? }
++ * POST /briefs/:id/reviews editors { kind, tone, decision, note? }
++ * GET /review-queue editors AI briefs and stories waiting for a person
* POST /stories/:id/flags news.story.revise { kind: correction|update, note }
* POST /stories/:id/sources news.source.attach { item: nsi_… | itm_… }
* DELETE /stories/:id/sources/:item news.source.attach
@@@ -72,7 +74,7 @@ function itemDto(it) {
}
function createApi(ctx) {
-- const { config, stories, clusters, publication, reading, topics, ingest, viewers, limits } = ctx;
++ const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits } = ctx;
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
@@@ -83,6 +85,24 @@ function createApi(ctx) {
router.use(limits.reads('news.read'));
const B = (name) => limits.budget(name);
++ // The reviewer queue: AI briefs and AI story revisions no person has reviewed yet, editor-only.
++ router.get('/review-queue', run(async (req) => {
++ access.requireEditor(config, req.viewer);
++ const items = [];
++ if (briefs) items.push(...await briefs.pendingReviews());
++ items.push(...await stories.pendingReviews());
++ return { items: items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)) };
++ }));
++
++ // A person reviews an AI brief. The review log is append-only; the brief page leaves noindex only
++ // while the approval still matches the brief (a remake waits for a person again).
++ router.post('/briefs/:id/reviews', B('news.story.publish'), jsonBody, run(async (req) => {
++ access.requireEditor(config, req.viewer);
++ if (!briefs) throw new ApiError(503, 'briefs.unavailable', 'AI briefs are not configured');
++ const b = req.body || {};
++ return { review: await briefs.review(req.viewer, req.params.id, { kind: b.kind, tone: b.tone, decision: b.decision, note: b.note }) };
++ }, 201));
++
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
/** Reads for editors: a service passed its capability guard; a browser must be an editor. */
const editorRead = (req) => { if (req.viewer.kind !== 'service') access.requireEditor(config, req.viewer); };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a969508..cf33857 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -33,6 +33,7 @@ const SECURITY_EXPIRES = '2027-10-29T00:00:00.000Z';
function createDiscoveryRoutes(ctx) {
const { config, store, publication, topics, stories, clusters } = ctx;
++ const briefs = ctx.briefs;
const homeData = ctx.home || createHome({ store });
const router = express.Router();
const { db } = store;
@@@ -147,7 +148,7 @@ function createDiscoveryRoutes(ctx) {
title: 'Pages',
pages: [
{ title: 'OpenVibe.News', url: abs('/'), text: SITE_SUMMARY },
-- { title: 'Topics', url: abs('/topics'), text: `The ${topicList.length} topics OpenVibe.News follows: ${topicList.map((t) => t.name).join(', ')}. Each topic page carries its stories, its AI briefs (labelled, noindex) and the wire of reports on it.` },
++ { title: 'Topics', url: abs('/topics'), text: `The ${topicList.length} topics OpenVibe.News follows: ${topicList.map((t) => t.name).join(', ')}. Each topic page carries its stories, its AI briefs (labelled; unreviewed ones noindex) and the wire of reports on it.` },
{ title: 'What shipped on OpenVibe.News', url: abs('/updates'), text: 'Every change deployed to OpenVibe.News, newest first.' },
],
},
@@@ -171,6 +172,19 @@ function createDiscoveryRoutes(ctx) {
}));
});
++ /** AI briefs a person reviewed while the approval still matches the text: the only briefs indexed. */
++ async function briefEntries() {
++ if (!briefs) return [];
++ const reviewed = await briefs.reviewed();
++ return reviewed.map((b) => {
++ const path = `/briefs/${b.cluster_id}`;
++ return {
++ loc: abs(path), lastmod: new Date(b.review.reviewedAt).toISOString(),
++ decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() }),
++ };
++ });
++ }
++
async function topicEntries(stories) {
const byTopic = new Map();
for (const e of stories) {
@@@ -197,11 +211,15 @@ function createDiscoveryRoutes(ctx) {
const known = times.filter((t) => Number.isFinite(t));
return known.length ? new Date(Math.max(...known)).toISOString() : undefined;
};
-- xml(res, seo.sitemapIndex([
++ const briefSitemap = await briefEntries();
++ const sections = [
{ loc: abs('/sitemaps/pages.xml'), lastmod: newest(site.filter((e) => e.decision.indexable).map((e) => new Date(e.lastmod).getTime())) },
{ loc: abs('/sitemaps/stories.xml'), lastmod: newest(indexed.map((e) => new Date(e.story.published_at).getTime())) },
{ loc: abs('/sitemaps/topics.xml'), lastmod: newest(topics.map((e) => new Date(e.lastmod).getTime())) },
-- ]));
++ ];
++ // A sitemap index carries no <loc> without a <lastmod>: list the briefs section only once one is reviewed.
++ if (briefSitemap.length) sections.push({ loc: abs('/sitemaps/briefs.xml'), lastmod: newest(briefSitemap.map((e) => new Date(e.lastmod).getTime())) });
++ xml(res, seo.sitemapIndex(sections));
});
// The site's own pages (never empty): what /llms.txt lists and a crawler reaches from the home page.
@@@ -217,6 +235,10 @@ function createDiscoveryRoutes(ctx) {
xml(res, seo.sitemap(await topicEntries(await storyEntries())).files[0]);
});
++ router.get('/sitemaps/briefs.xml', async (_req, res) => {
++ xml(res, seo.sitemap(await briefEntries()).files[0]);
++ });
++
return router;
}
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 74b40e3..6422125 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -158,7 +158,8 @@ function createPublicRoutes(ctx) {
}));
// ── AI briefs and the live view (domain/briefs.js, render/briefs.js) ──
-- // Briefs are AI text: labelled, every paragraph cited, never a story and never indexed. A reader may ask for another
++ // Briefs are AI text: labelled, every paragraph cited, never a story. They are noindex until a person reviews
++ // them (the /admin/review queue); a reviewed, current summary may be indexed. A reader may ask for another
// tone or the outlets' comparison (a POST, from this site, a few an address and a few dozen an hour in all), never a
// crawler following a link.
const noindex = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: seo.canonicalUrl(config.baseUrl, path), wordCount: 0, noindex: true }, { policy: { minWords: 0 }, now: store.now() });
@@@ -181,12 +182,20 @@ function createPublicRoutes(ctx) {
if (!got || !got.reports.length) return notFound(req, res);
const persp = await briefs.get(id, { kind: 'perspectives', tone: 'neutral' });
const notice = req.query.made === '1' ? 'Written just now.' : (req.query.failed ? String(req.query.failed).slice(0, 200) : null);
++ // A person's approval is what lets this page leave noindex. It counts only for the exact text
++ // reviewed: a remake changes created_at, so an old approval no longer applies.
++ const review = await briefs.reviewOf(id, { kind: 'summary', tone });
++ const reviewed = tone === 'neutral' && got.enough && got.fresh && briefs.isApproved(got.brief, review);
++ const canonical = `${config.baseUrl}/briefs/${id}`;
++ const decision = reviewed
++ ? seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: canonical, wordCount: 0 }, { policy: { minWords: 0 }, now: store.now() })
++ : noindex(`/briefs/${id}`);
send(req, res, 200, {
title: got.enough ? `${got.brief && got.brief.headline ? got.brief.headline : got.cluster.label} (AI brief)` : `${got.cluster.label} (reports)`,
description: got.enough ? 'An AI summary of the reports on this subject, every paragraph linked to its reports.' : 'The reports on this subject so far. Too few outlets have covered it for an AI summary.',
-- decision: noindex(`/briefs/${id}`), canonical: `${config.baseUrl}/briefs/${id}`, localStyles: ['css/briefs.css'],
-- body: briefPages.briefPage({ ...got, tone, perspectives: persp ? persp.brief : null, canMake: canMake(), notice }),
-- }, { cacheable: !notice });
++ decision, canonical, localStyles: ['css/briefs.css'],
++ body: briefPages.briefPage({ ...got, tone, perspectives: persp ? persp.brief : null, canMake: canMake(), notice, review: reviewed ? review : null }),
++ }, { cacheable: !notice && (reviewed || !got.brief) });
}));
router.post('/briefs/:id/make', makeLimit, express.urlencoded({ extended: false, limit: '4kb' }), wrap(async (req, res) => {
ddiff --git a/server/render/briefs.js b/server/render/briefs.js
iindex 0a9535c..1561ebd 100644
---- a/server/render/briefs.js
++++ b/server/render/briefs.js
@@@ -49,7 +49,7 @@ function reportsOnlyPage({ cluster, reports, outlets, minOutlets }) {
}
/** /briefs/:id */
--function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake, notice = null, enough = true, minOutlets = 2 }) {
++function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake, notice = null, enough = true, minOutlets = 2, review = null }) {
const outlets = [...new Set(reports.map((r) => r.outlet))];
if (!enough) return reportsOnlyPage({ cluster, reports, outlets, minOutlets });
const shown = brief && !brief.stub ? brief : null;
@@@ -59,7 +59,7 @@ function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake
const persp = perspectives && !perspectives.stub ? perspectives : null;
return String(h`<header class="site-header"><p class="eyebrow"><span class="badge badge-ai">AI brief</span> from ${coverage(reports, outlets)}</p>
<h1>${shown && shown.headline ? shown.headline : cluster.label}</h1>
--<p class="meta">${AI_NOTE}</p></header>
++<p class="meta">${review ? h`Reviewed by a person <time datetime="${new Date(review.reviewedAt).toISOString()}">${time(review.reviewedAt)}</time>. ` : ''}${AI_NOTE}</p></header>
${notice ? h`<p class="notice">${notice}</p>` : ''}
${tabs}
<section class="brief" aria-label="The brief">
ddiff --git a/test/review-queue.test.js b/test/review-queue.test.js
nnew file mode 100644
iindex 0000000..006d939
---- /dev/null
++++ b/test/review-queue.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The reviewer queue for AI briefs: editor-only listing, the review action, and the gate effect —
++ * a brief page is indexable while a person's approval matches the exact text, and a remake after
++ * the cluster changes puts it back in the queue and back to noindex.
++ */
++const assert = require('assert');
++const { boot, check, done, launchReports } = require('./helpers/boot');
++
++const run = (output, id = 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E1') => ({ status: 201, run: { id, status: 'succeeded', workflow: { key: 'news.summarize_story', version: 3 }, synthetic: false, provenance: { model: 'test-model' }, output } });
++
++(async () => {
++ const t = await boot({ ai: true });
++ const person = t.network.addUser('person');
++ try {
++ launchReports(t.sources);
++ await t.pull();
++ const clusterId = (await t.db().prepare("SELECT cluster_id, count(*) AS n FROM news_source_items WHERE status = 'active' GROUP BY cluster_id ORDER BY n DESC").get()).cluster_id;
++ t.ai.setNext(run({ headline: 'Europa Clipper is on its way', summary: 'NASA launched Europa Clipper toward Jupiter.', key_points: [{ text: 'It reaches Jupiter in 2030.', citations: [1] }], timeline: [], citations: [0, 1], gaps: [] }));
++ await t.ctx.briefs.sweep();
++
++ await check('the queue lists the AI brief, editor-only, with a review path', async () => {
++ const r = await t.api('/review-queue');
++ assert.strictEqual(r.status, 200, r.text);
++ const item = r.json().items.find((i) => i.id === clusterId);
++ assert.ok(item, 'the unreviewed brief is waiting');
++ assert.deepStrictEqual([item.site, item.kind], ['news', 'ai_brief']);
++ assert.strictEqual(item.review.path, `/api/v1/briefs/${clusterId}/reviews`);
++ assert.strictEqual(item.url, `/briefs/${clusterId}`);
++ assert.ok(item.reasons.some((x) => x.code === 'ai_generated_unreviewed' && /AI-written/.test(x.text)));
++ const no = await t.get('/api/v1/review-queue', { as: person });
++ assert.strictEqual(no.status, 403);
++ assert.strictEqual(no.json().code, 'news.editor_required');
++ });
++
++ await check('an approval through the service review makes the brief page indexable and lists it in the briefs sitemap', async () => {
++ const before = await t.get(`/briefs/${clusterId}`);
++ assert.ok(before.text.includes('noindex'));
++ const r = await t.api(`/briefs/${clusterId}/reviews`, { json: { kind: 'summary', tone: 'neutral', decision: 'approved', note: 'Checked against the reports' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().review.reviewer, t.editor.subject);
++ const page = await t.get(`/briefs/${clusterId}`);
++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'reviewed: indexable');
++ assert.ok(page.text.includes('Reviewed by a person'), 'the page says who reviewed it');
++ const sitemap = await t.get('/sitemaps/briefs.xml');
++ assert.ok(sitemap.text.includes(`/briefs/${clusterId}`), 'a reviewed brief is in the sitemap');
++ const q = await t.api('/review-queue');
++ assert.ok(!q.json().items.some((i) => i.id === clusterId), 'a reviewed brief leaves the queue');
++ const audit = await t.db().prepare('SELECT * FROM news_brief_reviews WHERE cluster_id = ?').all(clusterId);
++ assert.strictEqual(audit.length, 1, 'the review log is the audit');
++ });
++
++ await check('a reject keeps it hidden and out of the queue; a remake waits for a person again', async () => {
++ t.clock.advance(60 * 60 * 1000);
++ t.ai.setNext(run({ headline: 'Europa Clipper, remade', summary: 'The same reports, written again.', key_points: [], timeline: [], citations: [0], gaps: [] }, 'run_01J8Z3V9Q6N1X2Y3Z4A5B6C7E2'));
++ await t.ctx.briefs.make(clusterId, { kind: 'summary', tone: 'neutral' });
++ const q = await t.api('/review-queue');
++ const item = q.json().items.find((i) => i.id === clusterId);
++ assert.ok(item, 'a remake after its review is waiting again');
++ const r = await t.api(`/briefs/${clusterId}/reviews`, { json: { kind: 'summary', tone: 'neutral', decision: 'rejected', note: 'Not accurate' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const page = await t.get(`/briefs/${clusterId}`);
++ assert.ok(page.text.includes('noindex'), 'rejected: still noindex');
++ const after = await t.api('/review-queue');
++ assert.ok(!after.json().items.some((i) => i.id === clusterId), 'the rejection clears the queue for this text');
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });