openvibers/openvibe.blog Commits
Review queue: AI-written post heads waiting for a person (#199)
Changes
@@@ -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');@@@ -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) {@@@ -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.',nnew file mode 100644@@@ -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); });
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { slugify } = require('openvibe-publishing/taxonomy');
const { slugify } = require('openvibe-publishing/taxonomy');
+const seo = require('openvibe-publishing/seo');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
const { isMediaId } = require('openvibe-publishing/media');
const { isMediaId } = require('openvibe-publishing/media');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
@@ -504,6 +505,43 @@ function createPosts({ store, blogs, publication, access, outbox, log = console
@@ -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). */
/** A person's review of one revision (what lets an AI draft be published and indexed). */
async review(viewer, post, { revision, decision, note } = {}, ctx = {}) {
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');
if (!viewer || viewer.kind !== 'user' || !viewer.subject) throw new ApiError(403, 'review.person_required', 'Only a signed-in person can review a revision');
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
* DELETE /posts/:id blog.post.delete
* DELETE /posts/:id blog.post.delete
* GET /posts/:id/revisions[/:n], /posts/:id/diff?from&to&mode blog.post.read
* 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 }
* 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/reviews people only { revision, decision, note? }
* POST /posts/:id/attachments blog.post.update { media_id, role, alt, caption, position }
* POST /posts/:id/attachments blog.post.update { media_id, role, alt, caption, position }
* DELETE /posts/:id/attachments/:aid blog.post.update
* DELETE /posts/:id/attachments/:aid blog.post.update
@@ -65,6 +66,12 @@ function createApi(ctx) {
@@ -65,6 +66,12 @@ function createApi(ctx) {
const B = (name) => limits.budget(name);
const B = (name) => limits.budget(name);
router.use((req, res, next) => { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
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 });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
async function mustBlog(req) {
async function mustBlog(req) {
@@ -203,6 +203,11 @@ route('post', '/posts/{id}/revert', {
@@ -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() },
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', {
route('post', '/posts/{id}/reviews', {
operationId: 'reviewPostRevision', summary: 'Approve or reject an AI-authored revision', tags: ['revisions'], security: PERSON, parameters: [POST],
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.',
description: 'People only. An approved revision may then publish.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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); });