openvibers/openvibe.news Commits
Review queue: AI briefs and stories waiting for a person; reviewed briefs indexable (#209)
Changes
@@@ -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.## Ownsnnew file mode 100644@@@ -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();@@@ -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 outletsFROM 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 };@@@ -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 = nullreturn 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');@@@ -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); };@@@ -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;}@@@ -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) => {@@@ -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, canMakeconst 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">nnew file mode 100644@@@ -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); });
@@ -43,7 +43,7 @@ view. Stories stay editor-written (below); beside them News now has:
@@ -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.
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
- **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.
**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.
`NEWS_AI_BRIEFS=off` turns briefs off.
## Owns
## Owns
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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();
@@ -43,6 +43,15 @@ function createBriefs({ store, ai, log = console, minOutlets = 2 }) {
@@ -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,
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,
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`),
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,
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
(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
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 }) {
@@ -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. */
/** 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' } = {}) {
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 });
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 }) {
@@ -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) }));
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 };
module.exports = { createBriefs, TONES, TONE_TEXT, MIN_REPORTS };
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { slugify } = require('openvibe-publishing/taxonomy');
const { slugify } = require('openvibe-publishing/taxonomy');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
+const seo = require('openvibe-publishing/seo');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const { actorRef, canPublish } = require('./publication');
const { actorRef, canPublish } = require('./publication');
const access = require('./access');
const access = require('./access');
@@ -563,6 +564,36 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -563,6 +564,36 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
return await flagRow(story, { kind, note: n, createdBy: subject });
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). */
/** A person's review of one revision (what lets an AI draft be published and indexed). */
async review(viewer, story, { revision, decision, note } = {}, { traceparent } = {}) {
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');
if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
* POST /stories/:id/unpublish news.story.publish
* POST /stories/:id/unpublish news.story.publish
* POST /stories/:id/retract news.story.retract { note }
* POST /stories/:id/retract news.story.retract { note }
* POST /stories/:id/reviews people only { revision, decision, 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/flags news.story.revise { kind: correction|update, note }
* POST /stories/:id/sources news.source.attach { item: nsi_… | itm_… }
* POST /stories/:id/sources news.source.attach { item: nsi_… | itm_… }
* DELETE /stories/:id/sources/:item news.source.attach
* DELETE /stories/:id/sources/:item news.source.attach
@@ -72,7 +74,7 @@ function itemDto(it) {
@@ -72,7 +74,7 @@ function itemDto(it) {
}
}
function createApi(ctx) {
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();
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
router.use(viewers.middleware());
@@ -83,6 +85,24 @@ function createApi(ctx) {
@@ -83,6 +85,24 @@ function createApi(ctx) {
router.use(limits.reads('news.read'));
router.use(limits.reads('news.read'));
const B = (name) => limits.budget(name);
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 });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
/** Reads for editors: a service passed its capability guard; a browser must be an editor. */
/** 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); };
const editorRead = (req) => { if (req.viewer.kind !== 'service') access.requireEditor(config, req.viewer); };
@@ -33,6 +33,7 @@ const SECURITY_EXPIRES = '2027-10-29T00:00:00.000Z';
@@ -33,6 +33,7 @@ const SECURITY_EXPIRES = '2027-10-29T00:00:00.000Z';
function createDiscoveryRoutes(ctx) {
function createDiscoveryRoutes(ctx) {
const { config, store, publication, topics, stories, clusters } = ctx;
const { config, store, publication, topics, stories, clusters } = ctx;
+ const briefs = ctx.briefs;
const homeData = ctx.home || createHome({ store });
const homeData = ctx.home || createHome({ store });
const router = express.Router();
const router = express.Router();
const { db } = store;
const { db } = store;
@@ -147,7 +148,7 @@ function createDiscoveryRoutes(ctx) {
@@ -147,7 +148,7 @@ function createDiscoveryRoutes(ctx) {
title: 'Pages',
title: 'Pages',
pages: [
pages: [
{ title: 'OpenVibe.News', url: abs('/'), text: SITE_SUMMARY },
{ 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.' },
{ 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) {
@@ -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) {
async function topicEntries(stories) {
const byTopic = new Map();
const byTopic = new Map();
for (const e of stories) {
for (const e of stories) {
@@ -197,11 +211,15 @@ function createDiscoveryRoutes(ctx) {
@@ -197,11 +211,15 @@ function createDiscoveryRoutes(ctx) {
const known = times.filter((t) => Number.isFinite(t));
const known = times.filter((t) => Number.isFinite(t));
return known.length ? new Date(Math.max(...known)).toISOString() : undefined;
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/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/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())) },
{ 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.
// 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) {
@@ -217,6 +235,10 @@ function createDiscoveryRoutes(ctx) {
xml(res, seo.sitemap(await topicEntries(await storyEntries())).files[0]);
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;
return router;
}
}
@@ -158,7 +158,8 @@ function createPublicRoutes(ctx) {
@@ -158,7 +158,8 @@ function createPublicRoutes(ctx) {
}));
}));
// ── AI briefs and the live view (domain/briefs.js, render/briefs.js) ──
// ── 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
// 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.
// 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() });
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) {
@@ -181,12 +182,20 @@ function createPublicRoutes(ctx) {
if (!got || !got.reports.length) return notFound(req, res);
if (!got || !got.reports.length) return notFound(req, res);
const persp = await briefs.get(id, { kind: 'perspectives', tone: 'neutral' });
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);
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, {
send(req, res, 200, {
title: got.enough ? `${got.brief && got.brief.headline ? got.brief.headline : got.cluster.label} (AI brief)` : `${got.cluster.label} (reports)`,
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.',
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) => {
router.post('/briefs/:id/make', makeLimit, express.urlencoded({ extended: false, limit: '4kb' }), wrap(async (req, res) => {
@@ -49,7 +49,7 @@ function reportsOnlyPage({ cluster, reports, outlets, minOutlets }) {
@@ -49,7 +49,7 @@ function reportsOnlyPage({ cluster, reports, outlets, minOutlets }) {
}
}
/** /briefs/:id */
/** /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))];
const outlets = [...new Set(reports.map((r) => r.outlet))];
if (!enough) return reportsOnlyPage({ cluster, reports, outlets, minOutlets });
if (!enough) return reportsOnlyPage({ cluster, reports, outlets, minOutlets });
const shown = brief && !brief.stub ? brief : null;
const shown = brief && !brief.stub ? brief : null;
@@ -59,7 +59,7 @@ function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake
@@ -59,7 +59,7 @@ function briefPage({ cluster, reports, brief, fresh, tone, perspectives, canMake
const persp = perspectives && !perspectives.stub ? perspectives : null;
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>
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>
<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>` : ''}
${notice ? h`<p class="notice">${notice}</p>` : ''}
${tabs}
${tabs}
<section class="brief" aria-label="The brief">
<section class="brief" aria-label="The brief">
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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); });