openvibers/openvibe.trade Commits
Review queue: company pages whose AI context waits for a person (#208)
Changes
@@@ -218,6 +218,34 @@ function createContext({ store, config, ctx }) {return rev ? await api.view(instrument, rev) : null;},++ /**++ * Company pages with an AI context revision above the published one and no review yet:++ * what waits for a person before the page can be indexed. Editor-only (checked by the route).++ */++ async pendingReviews() {++ const rows = await db.prepare(`SELECT r.*, i.symbol, i.name, i.context_published_revision++ FROM trade_context_revisions r JOIN trade_instruments i ON i.id = r.entity_id++ WHERE i.status = 'active' AND r.number > COALESCE(i.context_published_revision, 0)++ ORDER BY r.created_at, r.id LIMIT 1000`).all();++ const items = [];++ for (const row of rows) {++ const rec = row.meta && row.meta.authorship;++ if (!rec || !authorship.needsReview(rec)) continue;++ const review = await store.reviews.latest(row.entity_id, row.number);++ if (review) continue;++ items.push({++ site: 'trade', kind: 'company_page', id: row.entity_id,++ title: `${row.symbol} — ${row.name}`,++ revision: row.number, state: row.number > (row.context_published_revision || 0) ? 'draft' : 'published',++ url: ctx.urls.instrument({ symbol: row.symbol }), editUrl: `/editor/i/${encodeURIComponent(row.symbol)}`,++ createdAt: new Date(row.created_at).toISOString(),++ reasons: [{ code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'company context is an AI draft', text: 'AI-written and not reviewed by a person.' }],++ review: { method: 'POST', path: `/api/v1/instruments/${encodeURIComponent(row.symbol)}/context/revisions/${row.number}/review` },++ });++ }++ return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));++ },++/** Revisions after the published one (drafts awaiting review or publication). */async pending(instrument) {const published = instrument.context_published_revision || 0;@@@ -22,6 +22,7 @@* GET /instruments/:symbol/context/input trade.context.read (OpenVibe.AI workflow input)* POST /instruments/:symbol/context trade.context.propose (AI drafts with X-OV-Origin: ai; editors)* POST /instruments/:symbol/context/revisions/:n/review people on the editor list only++ * GET /review-queue editors: company pages whose AI context waits for review* GET /watchlists trade.watchlist.read* POST /watchlists trade.watchlist.create* GET /watchlists/:id trade.watchlist.read@@@ -101,6 +102,12 @@ function createApi(ctx) {offset: Math.max(parseInt(req.query.offset, 10) || 0, 0),});++ // The reviewer queue: company pages whose AI context draft waits for a person. Editors only.++ define(router, 'get', '/review-queue', 'getReviewQueue', run(async (req) => {++ if (!(req.viewer.kind === 'user' && req.viewer.editor)) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors review the publication queue');++ return { items: await context.pendingReviews() };++ }));++// ── Instruments ─────────────────────────────────────────define(router, 'get', '/instruments', 'getInstruments', run(async (req) => {nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * GET /api/v1/review-queue: a company page whose AI context draft waits for a person, editor-only,++ * with the review path that publishes it. Reviewing it clears the queue and makes the page indexable.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');++++(async () => {++ const t = await boot();++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });++ const { observation } = await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });++ await t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });++ const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);++ try {++ await check('an AI context draft waits in the queue; a non-editor gets 403', async () => {++ const input = await t.get('/api/v1/instruments/ACME/context/input', { as: ai });++ const r = await t.get('/api/v1/instruments/ACME/context', {++ as: ai, headers: { 'x-ov-origin': 'ai' },++ json: {++ workflow: { id: 'trade.summarize_market_context', run_id: 'run_01JABCDEFGHJKMNPQRSTVWXYZ0', version: 1 },++ input_sources: input.json().input.sources.map((s) => ({ source_type: s.source_type, source_id: s.source_id })),++ output: { summary: `The latest recorded close for ACME is 10.50 USD. ${WORDS(70)}`, observations: [{ text: 'Close of 10.50 USD', observed_at: t.iso(t.T0 - 60e3), citations: [0] }], stale_sources: [], disclaimer: 'Informational only; not investment advice.', citations: [0], gaps: [] },++ },++ });++ assert.strictEqual(r.status, 201, r.text);++ const q = await t.get('/api/v1/review-queue', { as: t.editor });++ assert.strictEqual(q.status, 200, q.text);++ const item = q.json().items.find((i) => i.id === acme.id);++ assert.ok(item, 'the AI draft is waiting');++ assert.deepStrictEqual([item.site, item.kind, item.revision], ['trade', 'company_page', 1]);++ assert.strictEqual(item.review.path, '/api/v1/instruments/ACME/context/revisions/1/review');++ 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: t.alice });++ assert.strictEqual(no.status, 403);++ assert.strictEqual(no.json().code, 'editor.forbidden');++ });++++ await check('approving through the context review publishes it, clears the queue and indexes the page', async () => {++ const r = await t.get('/api/v1/instruments/ACME/context/revisions/1/review', { as: t.editor, json: { decision: 'approved', note: 'Checked against the observation' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().published, true);++ const q = await t.get('/api/v1/review-queue', { as: t.editor });++ assert.ok(!q.json().items.some((i) => i.id === acme.id), 'a reviewed context leaves the queue');++ const page = await t.get('/i/ACME');++ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'the company page is indexable');++ assert.ok(page.text.includes('reviewed by a person'));++ const audit = await t.ctx.store.db.prepare('SELECT * FROM trade_context_reviews WHERE entity_id = ?').all(acme.id);++ assert.strictEqual(audit.length, 1, 'the context review log is the audit');++ });++ } finally {++ await t.close();++ done();++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -218,6 +218,34 @@ function createContext({ store, config, ctx }) {
@@ -218,6 +218,34 @@ function createContext({ store, config, ctx }) {
return rev ? await api.view(instrument, rev) : null;
return rev ? await api.view(instrument, rev) : null;
},
},
+ /**
+ * Company pages with an AI context revision above the published one and no review yet:
+ * what waits for a person before the page can be indexed. Editor-only (checked by the route).
+ */
+ async pendingReviews() {
+ const rows = await db.prepare(`SELECT r.*, i.symbol, i.name, i.context_published_revision
+ FROM trade_context_revisions r JOIN trade_instruments i ON i.id = r.entity_id
+ WHERE i.status = 'active' AND r.number > COALESCE(i.context_published_revision, 0)
+ ORDER BY r.created_at, r.id LIMIT 1000`).all();
+ const items = [];
+ for (const row of rows) {
+ const rec = row.meta && row.meta.authorship;
+ if (!rec || !authorship.needsReview(rec)) continue;
+ const review = await store.reviews.latest(row.entity_id, row.number);
+ if (review) continue;
+ items.push({
+ site: 'trade', kind: 'company_page', id: row.entity_id,
+ title: `${row.symbol} — ${row.name}`,
+ revision: row.number, state: row.number > (row.context_published_revision || 0) ? 'draft' : 'published',
+ url: ctx.urls.instrument({ symbol: row.symbol }), editUrl: `/editor/i/${encodeURIComponent(row.symbol)}`,
+ createdAt: new Date(row.created_at).toISOString(),
+ reasons: [{ code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'company context is an AI draft', text: 'AI-written and not reviewed by a person.' }],
+ review: { method: 'POST', path: `/api/v1/instruments/${encodeURIComponent(row.symbol)}/context/revisions/${row.number}/review` },
+ });
+ }
+ return items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
+ },
+
/** Revisions after the published one (drafts awaiting review or publication). */
/** Revisions after the published one (drafts awaiting review or publication). */
async pending(instrument) {
async pending(instrument) {
const published = instrument.context_published_revision || 0;
const published = instrument.context_published_revision || 0;
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
* GET /instruments/:symbol/context/input trade.context.read (OpenVibe.AI workflow input)
* GET /instruments/:symbol/context/input trade.context.read (OpenVibe.AI workflow input)
* POST /instruments/:symbol/context trade.context.propose (AI drafts with X-OV-Origin: ai; editors)
* POST /instruments/:symbol/context trade.context.propose (AI drafts with X-OV-Origin: ai; editors)
* POST /instruments/:symbol/context/revisions/:n/review people on the editor list only
* POST /instruments/:symbol/context/revisions/:n/review people on the editor list only
+ * GET /review-queue editors: company pages whose AI context waits for review
* GET /watchlists trade.watchlist.read
* GET /watchlists trade.watchlist.read
* POST /watchlists trade.watchlist.create
* POST /watchlists trade.watchlist.create
* GET /watchlists/:id trade.watchlist.read
* GET /watchlists/:id trade.watchlist.read
@@ -101,6 +102,12 @@ function createApi(ctx) {
@@ -101,6 +102,12 @@ function createApi(ctx) {
offset: Math.max(parseInt(req.query.offset, 10) || 0, 0),
offset: Math.max(parseInt(req.query.offset, 10) || 0, 0),
});
});
+ // The reviewer queue: company pages whose AI context draft waits for a person. Editors only.
+ define(router, 'get', '/review-queue', 'getReviewQueue', run(async (req) => {
+ if (!(req.viewer.kind === 'user' && req.viewer.editor)) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors review the publication queue');
+ return { items: await context.pendingReviews() };
+ }));
+
// ── Instruments ─────────────────────────────────────────
// ── Instruments ─────────────────────────────────────────
define(router, 'get', '/instruments', 'getInstruments', run(async (req) => {
define(router, 'get', '/instruments', 'getInstruments', run(async (req) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * GET /api/v1/review-queue: a company page whose AI context draft waits for a person, editor-only,
+ * with the review path that publishes it. Reviewing it clears the queue and makes the page indexable.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const WORDS = (n) => Array.from({ length: n }, (_, i) => `word${i}`).join(' ');
+
+(async () => {
+ const t = await boot();
+ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
+ const { observation } = await t.observe(acme, { metric: 'price.close', value: '10.50', source_ref: 'a', observed_at: t.iso(t.T0 - 60e3), retrieved_at: t.iso(t.T0) });
+ await t.ctx.freshness.report('test-feed', { status: 'healthy', lastSuccessAt: t.T0, staleAfterSec: 86400 });
+ const ai = t.network.serviceToken('ai', ['trade.context.read', 'trade.context.propose']);
+ try {
+ await check('an AI context draft waits in the queue; a non-editor gets 403', async () => {
+ const input = await t.get('/api/v1/instruments/ACME/context/input', { as: ai });
+ const r = await t.get('/api/v1/instruments/ACME/context', {
+ as: ai, headers: { 'x-ov-origin': 'ai' },
+ json: {
+ workflow: { id: 'trade.summarize_market_context', run_id: 'run_01JABCDEFGHJKMNPQRSTVWXYZ0', version: 1 },
+ input_sources: input.json().input.sources.map((s) => ({ source_type: s.source_type, source_id: s.source_id })),
+ output: { summary: `The latest recorded close for ACME is 10.50 USD. ${WORDS(70)}`, observations: [{ text: 'Close of 10.50 USD', observed_at: t.iso(t.T0 - 60e3), citations: [0] }], stale_sources: [], disclaimer: 'Informational only; not investment advice.', citations: [0], gaps: [] },
+ },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const q = await t.get('/api/v1/review-queue', { as: t.editor });
+ assert.strictEqual(q.status, 200, q.text);
+ const item = q.json().items.find((i) => i.id === acme.id);
+ assert.ok(item, 'the AI draft is waiting');
+ assert.deepStrictEqual([item.site, item.kind, item.revision], ['trade', 'company_page', 1]);
+ assert.strictEqual(item.review.path, '/api/v1/instruments/ACME/context/revisions/1/review');
+ 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: t.alice });
+ assert.strictEqual(no.status, 403);
+ assert.strictEqual(no.json().code, 'editor.forbidden');
+ });
+
+ await check('approving through the context review publishes it, clears the queue and indexes the page', async () => {
+ const r = await t.get('/api/v1/instruments/ACME/context/revisions/1/review', { as: t.editor, json: { decision: 'approved', note: 'Checked against the observation' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().published, true);
+ const q = await t.get('/api/v1/review-queue', { as: t.editor });
+ assert.ok(!q.json().items.some((i) => i.id === acme.id), 'a reviewed context leaves the queue');
+ const page = await t.get('/i/ACME');
+ assert.ok(page.text.includes('<meta name="robots" content="index, follow">'), 'the company page is indexable');
+ assert.ok(page.text.includes('reviewed by a person'));
+ const audit = await t.ctx.store.db.prepare('SELECT * FROM trade_context_reviews WHERE entity_id = ?').all(acme.id);
+ assert.strictEqual(audit.length, 1, 'the context review log is the audit');
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });