openvibers/openvibe.trade Commits

Review queue: company pages whose AI context waits for a person (#208)

OP OpenVibers committed 7 days ago · f6b4680970 · 3 files, +92 −0

Changes

ddiff --git a/server/domain/context.js b/server/domain/context.js
iindex 26b154c..fb744cf 100644
---- a/server/domain/context.js
++++ b/server/domain/context.js
@@@ -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;
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 498dc34..aefc8e1 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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) => {
ddiff --git a/test/review-queue.test.js b/test/review-queue.test.js
nnew file mode 100644
iindex 0000000..7d66bf4
---- /dev/null
++++ b/test/review-queue.test.js
@@@ -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); });