openvibers/openvibe.wiki Commits

Review queue: published AI-assisted pages waiting for a person (#214)

OP OpenVibers committed 7 days ago · 540a0b951e · 4 files, +94 −1

Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex ff7627b..0cb688c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -22,6 +22,7 @@
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations wiki.page.read
* POST /pages/:id/revisions/:n/citations wiki.citation.attach { citations: [...] } (unpublished head only)
++ * GET /review-queue staff: published AI-assisted pages waiting for a person
* POST /pages/:id/revisions/:n/review wiki.revision.publish { decision: approved|rejected, note? } a person (owner/editor)
* POST /pages/:id/publish wiki.revision.publish { revision? }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
@@@ -107,6 +108,15 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
router.use(limits.reads('wiki.read'));
const B = (name) => limits.budget(name);
const R = (fn, status) => run(fn, status, log);
++
++ // The reviewer queue: everything published and public whose AI-assisted text waits for a person.
++ // Staff only here; the service also drops pages this actor may not edit.
++ router.get('/review-queue', R(async (req) => {
++ if (!req.actor || req.actor.kind !== 'user' || !req.actor.staff) {
++ throw new svc.WikiError(403, 'review.forbidden', 'Only OpenVibe staff review the publication queue');
++ }
++ return { items: await svc.pendingReviews(req.actor) };
++ }));
// VIP (WS-K task 8): ask VIP about the space or page a route names before its synchronous access check.
const prepare = (req, pairs) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs) : null);
router.param('space', (req, res, next, ref) => {
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex 8d751c5..b282bdc 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -177,6 +177,11 @@ route('post', '/pages/{id}/revisions/{n}/citations', {
requestBody: body({ type: 'object', required: ['citations'], properties: { citations: arrayOf(citation) } }),
responses: { 201: json('The revision\'s citations', { type: 'object', properties: { citations: arrayOf(ref('Citation')) } }), ...errors() },
});
++route('get', '/review-queue', {
++ operationId: 'getReviewQueue', summary: 'The reviewer queue: published AI-assisted pages waiting for a person', tags: ['revisions'], security: PERSON,
++ description: 'Staff only. Oldest first, with the gate\'s reason codes and words and the review path for each page.',
++ responses: { 200: json('The waiting pages', { type: 'object', properties: { items: arrayOf({ type: 'object' }) } }), ...errors([401, 403, 429]) },
++});
route('post', '/pages/{id}/revisions/{n}/review', {
operationId: 'reviewRevision', summary: 'Approve or reject a revision', tags: ['revisions'], security: PERSON, parameters: [PAGE, REVISION],
description: 'wiki.revision.publish. A person (owner or editor); an approved AI-assisted revision becomes indexable when it publishes.',
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 7b1344a..ea7794c 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -551,7 +551,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
pages.forEach((page, i) => {
const space = spaces.get(page.space_id);
if (!space || !revs[i]) return;
-- out.push({ space, page, rev: revs[i], decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });
++ out.push({ space, page, rev: revs[i], review: revws[i] || null, decision: decideWith(space, page, revs[i], cites[i] || [], revws[i]) });
});
return out;
}
@@@ -1185,6 +1185,34 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// Discovery (public, indexable only) -----------------------------------------------
async publishedPublic() { return await expandPublished(await q.allPublishedPublic.all()); },
++ /**
++ * Published, public pages whose text nobody has approved yet: AI-assisted imports and
++ * AI-generated revisions held noindex by the gate. Only pages this actor may review.
++ * Oldest first, with the gate's own reason codes and words for each.
++ */
++ async pendingReviews(actor) {
++ await roles(actor);
++ const items = [];
++ for (const x of await expandPublished(await q.allPublishedPublic.all())) {
++ if (!access.canEdit(x.space, actor)) continue;
++ const rec = x.rev.meta && x.rev.meta.authorship;
++ if (!rec || !(aiAssistedImport(rec) || authorship.needsReview(rec))) continue;
++ if (x.review) continue; // a decision, approved or rejected, has been made for this revision
++ items.push({
++ site: 'wiki',
++ kind: 'wiki_page',
++ id: x.page.id,
++ title: x.rev.fields.title || x.page.title,
++ revision: x.page.published_revision,
++ url: pageUrl(x.space, x.page),
++ createdAt: new Date(x.page.revision_published_at || x.page.published_at || x.rev.createdAt).toISOString(),
++ reasons: x.decision.reasons.map((r) => ({ ...r, text: (seo.REASON_BY_CODE[r.code] || {}).description || r.code })),
++ review: { method: 'POST', path: `/api/v1/pages/${encodeURIComponent(x.page.id)}/revisions/${x.page.published_revision}/review` },
++ });
++ }
++ return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
++ },
++
/** The home's numbers (public wikis and pages only): wikis, pages, revisions, citations, distinct sources, links between pages. */
async homeStats() {
const r = (await q.homeStats.get()) || {};
ddiff --git a/test/review-queue.test.js b/test/review-queue.test.js
nnew file mode 100644
iindex 0000000..b164b71
---- /dev/null
++++ b/test/review-queue.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/**
++ * GET /api/v1/review-queue: the published pages whose AI-assisted text nobody has reviewed yet,
++ * staff only, oldest first, with the gate's reason in words and the review path that clears it.
++ * Approving a page through its own review route removes it from the queue.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const H = require('./helpers');
++const { seed, SYSTEM } = require('../server/wiki/seed');
++
++const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'openvibe.json'), 'utf8'));
++
++(async () => {
++ const h = await H.boot({ env: { WIKI_GATE_MIN_WORDS: '80' } });
++ const staff = H.subject(), person = H.subject();
++ const tok = (sub, role = 'user') => H.userToken({ subject: sub, role });
++ try {
++ await seed(h.svc, data, { log: H.quiet });
++ const space = await h.svc.findSpace('openvibe');
++ const page = await h.svc.findPage(space.id, 'openvibe-search');
++
++ let r = await H.req(h, 'GET', '/api/v1/review-queue', { token: tok(staff, 'admin') });
++ assert.strictEqual(r.status, 200, r.text);
++ const item = r.json.items.find((i) => i.id === page.id);
++ assert.ok(item, 'the seeded AI-assisted page is waiting');
++ assert.deepStrictEqual([item.site, item.kind, item.revision], ['wiki', 'wiki_page', page.published_revision]);
++ assert.strictEqual(item.url, `http://wiki.test/w/openvibe/${page.slug}`);
++ assert.match(item.title, /Search|search/i);
++ assert.ok(item.reasons.some((x) => x.code === 'ai_generated_unreviewed' && /AI-generated/.test(x.text)), 'the gate reason is in words');
++ assert.strictEqual(item.review.path, `/api/v1/pages/${page.id}/revisions/${page.published_revision}/review`);
++ assert.ok(Date.parse(item.createdAt) > 0);
++ assert.deepStrictEqual(r.json.items.map((i) => Date.parse(i.createdAt)), r.json.items.map((i) => Date.parse(i.createdAt)).sort((a, b) => a - b), 'oldest first');
++
++ r = await H.req(h, 'GET', '/api/v1/review-queue', { token: tok(person) });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json.code, 'review.forbidden');
++ assert.strictEqual((await H.req(h, 'GET', '/api/v1/review-queue')).status, 403);
++
++ const approve = await H.req(h, 'POST', item.review.path, { token: tok(staff, 'admin'), body: { decision: 'approved', note: 'Checked against the README' } });
++ assert.strictEqual(approve.status, 201, approve.text);
++ r = await H.req(h, 'GET', '/api/v1/review-queue', { token: tok(staff, 'admin') });
++ assert.ok(!r.json.items.some((i) => i.id === page.id), 'a reviewed page leaves the queue');
++ assert.strictEqual((await h.stores.reviews.history(page.id)).length, 1, 'the service review log is the audit');
++ } finally {
++ await h.stop();
++ }
++ console.log('review-queue.test.js: ok');
++})().catch((e) => { console.error(e); process.exit(1); });