openvibers/openvibe.blog Commits

Review queue: AI-written post heads waiting for a person (#199)

OP OpenVibers committed 7 days ago · ae80f756a5 · 4 files, +124 −0

Changes

ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex ca1066e..7b6a5c1 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -18,6 +18,7 @@
*/
const { ids } = require('openvibe-contracts');
const { slugify } = require('openvibe-publishing/taxonomy');
++const seo = require('openvibe-publishing/seo');
const authorship = require('openvibe-publishing/authorship');
const { isMediaId } = require('openvibe-publishing/media');
const { ApiError } = require('../http/errors');
@@@ -504,6 +505,43 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
});
},
++ /**
++ * Posts whose head revision is AI-generated and nobody has approved it yet: what a person
++ * still has to review before the post can be published or indexed. Only posts this viewer
++ * may review, oldest first. The gate's own reasons and words come with each item.
++ */
++ async pendingReviews(viewer) {
++ const rows = await db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs b ON b.id = p.blog_id
++ WHERE p.deleted_at IS NULL AND p.state IN ('draft', 'published') AND b.status = 'active'
++ ORDER BY p.created_at, p.id LIMIT 1000`).all();
++ const items = [];
++ for (const post of rows) {
++ const blog = await blogOf(post);
++ if (!blog || !await access.canWrite(store, viewer, blog, 'publish', post)) continue;
++ const head = await store.revisions.head(post.id);
++ const rec = publication.authorshipOf(head);
++ if (!head || !rec || !authorship.needsReview(rec)) continue;
++ const review = await store.reviews.latest(post.id, head.number);
++ if (review) continue; // a decision, approved or rejected, has been made for this revision
++ const decision = await publication.decide(blog, post, head);
++ items.push({
++ site: 'blog',
++ kind: post.state === 'draft' ? 'blog_draft' : 'blog_post',
++ id: post.id,
++ title: head.fields.title || post.slug,
++ revision: head.number,
++ state: post.state,
++ url: publication.postUrl(blog, post),
++ editUrl: `/write/posts/${post.id}`,
++ createdAt: new Date(head.createdAt).toISOString(),
++ reasons: decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
++ review: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.id)}/reviews` },
++ ...(post.state === 'draft' ? { publish: { method: 'POST', path: `/api/v1/posts/${encodeURIComponent(post.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, post, { revision, decision, note } = {}, ctx = {}) {
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 3872b9c..203cb57 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -24,6 +24,7 @@
* DELETE /posts/:id blog.post.delete
* GET /posts/:id/revisions[/:n], /posts/:id/diff?from&to&mode blog.post.read
* POST /posts/:id/revert blog.post.update { to_revision, expected_revision }
++ * GET /review-queue editorial staff posts whose AI-written head waits for a person
* POST /posts/:id/reviews people only { revision, decision, note? }
* POST /posts/:id/attachments blog.post.update { media_id, role, alt, caption, position }
* DELETE /posts/:id/attachments/:aid blog.post.update
@@@ -65,6 +66,12 @@ function createApi(ctx) {
const B = (name) => limits.budget(name);
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
++ // The reviewer queue (editorial staff): every post whose AI-written head waits for a person.
++ router.get('/review-queue', run(async (req) => {
++ if (!req.viewer || !req.viewer.editorial) throw new ApiError(403, 'review.forbidden', 'Only OpenVibe editorial staff review the publication queue');
++ return { items: await posts.pendingReviews(req.viewer) };
++ }));
++
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
async function mustBlog(req) {
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex cf9c352..dcba7d8 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -203,6 +203,11 @@ route('post', '/posts/{id}/revert', {
} }),
responses: { 201: json('The revert revision and the post', { type: 'object', properties: { revision: { type: 'integer' }, post: ref('Post') } }), ...errors() },
});
++route('get', '/review-queue', {
++ operationId: 'getReviewQueue', summary: 'The reviewer queue: posts whose AI-written head waits for a person', tags: ['revisions'], security: PERSON,
++ description: 'Editorial staff only. Oldest first, with the gate\'s reason codes and words and the review path for each post.',
++ responses: { 200: json('The waiting posts', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
++});
route('post', '/posts/{id}/reviews', {
operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],
description: 'People only. An approved revision may then publish.',
ddiff --git a/test/review-queue.test.js b/test/review-queue.test.js
nnew file mode 100644
iindex 0000000..093f518
---- /dev/null
++++ b/test/review-queue.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * GET /api/v1/review-queue: the posts whose AI-written head revision nobody has reviewed yet,
++ * editorial staff only, oldest first, with the gate's reasons and both actions (the service's
++ * own review route, and publish for a draft). Reviewing and publishing clears the queue.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
++const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7Q8R9S0T1V2W3' };
++
++(async () => {
++ const t = await boot();
++ const lead = t.official; // the official blog's owner (BLOG_OFFICIAL_OWNERS)
++ const admin = t.network.addUser('admin', { role: 'admin' });
++ const person = t.network.addUser('person');
++ const ai = t.network.serviceToken('ai', ['blog.post.create', 'blog.post.read', 'blog.post.publish']);
++ let draft;
++ try {
++ await check('an AI draft on the official blog waits in the queue', async () => {
++ const r = await t.get('/api/v1/blogs/openvibe/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': lead.subject }, json: { title: 'An AI draft', body: LONG, authorship: { workflow: WORKFLOW } } });
++ assert.strictEqual(r.status, 201, r.text);
++ draft = r.json().post;
++ const q = await t.get('/api/v1/review-queue', { as: admin });
++ assert.strictEqual(q.status, 200, q.text);
++ const item = q.json().items.find((i) => i.id === draft.id);
++ assert.ok(item, 'the AI draft is waiting');
++ assert.deepStrictEqual([item.site, item.kind, item.state, item.revision], ['blog', 'blog_draft', 'draft', 1]);
++ assert.strictEqual(item.title, 'An AI draft');
++ assert.strictEqual(item.review.path, `/api/v1/posts/${draft.id}/reviews`);
++ assert.strictEqual(item.publish.path, `/api/v1/posts/${draft.id}/publish`);
++ assert.ok(item.reasons.some((x) => x.code === 'ai_generated_unreviewed' && /AI-generated/.test(x.text)));
++ assert.deepStrictEqual(q.json().items.map((i) => Date.parse(i.createdAt)), q.json().items.map((i) => Date.parse(i.createdAt)).sort((a, b) => a - b));
++ });
++
++ await check('a non-staff person gets 403', async () => {
++ const r = await t.get('/api/v1/review-queue', { as: person });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'review.forbidden');
++ });
++
++ await check('approve through the post\'s own review, then publish, clears it and the page is indexable', async () => {
++ const q = await t.get('/api/v1/review-queue', { as: admin });
++ const item = q.json().items.find((i) => i.id === draft.id);
++ const review = await t.get(item.review.path, { as: admin, json: { revision: item.revision, decision: 'approved', note: 'Checked' } });
++ assert.strictEqual(review.status, 201, review.text);
++ const pub = await t.get(item.publish.path, { as: admin, json: { revision: item.revision } });
++ assert.strictEqual(pub.status, 200, pub.text);
++ assert.strictEqual(pub.json().post.state, 'published');
++ const after = await t.get('/api/v1/review-queue', { as: admin });
++ assert.ok(!after.json().items.some((i) => i.id === draft.id), 'a reviewed post leaves the queue');
++ const page = await t.get(new URL(item.url).pathname);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /<meta name="robots" content="index, follow">/);
++ assert.ok((await t.ctx.store.reviews.history(draft.id)).length >= 1, 'the review log is the audit');
++ });
++
++ await check('a rejected revision stays hidden and leaves the queue', async () => {
++ const r = await t.get('/api/v1/blogs/openvibe/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': lead.subject }, json: { title: 'Another AI draft', body: LONG, authorship: { workflow: WORKFLOW } } });
++ assert.strictEqual(r.status, 201, r.text);
++ const post = r.json().post;
++ const q = await t.get('/api/v1/review-queue', { as: admin });
++ const item = q.json().items.find((i) => i.id === post.id);
++ const rej = await t.get(item.review.path, { as: admin, json: { revision: item.revision, decision: 'rejected', note: 'Needs a person' } });
++ assert.strictEqual(rej.status, 201, rej.text);
++ const after = await t.get('/api/v1/review-queue', { as: admin });
++ assert.ok(!after.json().items.some((i) => i.id === post.id), 'a rejected revision is reviewed and leaves the queue');
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });