openvibers/openvibe.wiki Commits
Review queue: published AI-assisted pages waiting for a person (#214)
Changes
@@@ -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, viberouter.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) => {@@@ -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.',@@@ -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()) || {};nnew file mode 100644@@@ -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); });
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/diff?from=&to=&mode= wiki.page.read
* GET /pages/:id/revisions/:n/citations 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)
* 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/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/publish wiki.revision.publish { revision? }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
@@ -107,6 +108,15 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
@@ -107,6 +108,15 @@ function createApi({ svc, viewers, platform, config, log = console, limits, vibe
router.use(limits.reads('wiki.read'));
router.use(limits.reads('wiki.read'));
const B = (name) => limits.budget(name);
const B = (name) => limits.budget(name);
const R = (fn, status) => run(fn, status, log);
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.
// 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);
const prepare = (req, pairs) => (req.actor && req.actor.subject ? svc.access.prepareVip(req.actor, pairs) : null);
router.param('space', (req, res, next, ref) => {
router.param('space', (req, res, next, ref) => {
@@ -177,6 +177,11 @@ route('post', '/pages/{id}/revisions/{n}/citations', {
@@ -177,6 +177,11 @@ route('post', '/pages/{id}/revisions/{n}/citations', {
requestBody: body({ type: 'object', required: ['citations'], properties: { citations: arrayOf(citation) } }),
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() },
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', {
route('post', '/pages/{id}/revisions/{n}/review', {
operationId: 'reviewRevision', summary: 'Approve or reject a revision', tags: ['revisions'], security: PERSON, parameters: [PAGE, REVISION],
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.',
description: 'wiki.revision.publish. A person (owner or editor); an approved AI-assisted revision becomes indexable when it publishes.',
@@ -551,7 +551,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -551,7 +551,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
pages.forEach((page, i) => {
pages.forEach((page, i) => {
const space = spaces.get(page.space_id);
const space = spaces.get(page.space_id);
if (!space || !revs[i]) return;
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;
return out;
}
}
@@ -1185,6 +1185,34 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1185,6 +1185,34 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// Discovery (public, indexable only) -----------------------------------------------
// Discovery (public, indexable only) -----------------------------------------------
async publishedPublic() { return await expandPublished(await q.allPublishedPublic.all()); },
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. */
/** The home's numbers (public wikis and pages only): wikis, pages, revisions, citations, distinct sources, links between pages. */
async homeStats() {
async homeStats() {
const r = (await q.homeStats.get()) || {};
const r = (await q.homeStats.get()) || {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });