openvibers/openvibe.trade Commits

Trade: a daily market-wide AI brief (#210)

OP OpenVibers committed 7 days ago · 88f23c7623 · 13 files, +743 −16

* Trade: a daily market-wide AI brief

One AI-written summary a UTC day from the day's filings by form, euro reference rates, companies with new filings or figures and aggregate watchlist activity. Every point links to its Trade page; the home stays noindex until an editor approves it from the review queue.

* Trade: give each market brief its own gated page

The home no longer changes indexability for a brief. It shows the latest reviewed brief in full, or one line linking to the unreviewed one. Each brief lives at /market/<date>, noindex until a person approves it and in the pages sitemap after; the review queue item links there too.

Changes

ddiff --git a/.env.example b/.env.example
iindex 1c8c9e3..d44044a 100644
---- a/.env.example
++++ b/.env.example
@@@ -65,6 +65,16 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
# default 600000; 0 rebuilds on every read, the default elsewhere). Concurrent reads share one build either way.
#TRADE_SITEMAP_TTL_MS=600000
++# OpenVibe.AI — the instrument briefs and the daily market brief. Unset: off, nothing is made or shown.
++#OV_AI_INTERNAL_URL=http://127.0.0.1:4800
++#TRADE_AI_WAIT_MS=60000
++#TRADE_BRIEFS_PER_TICK=6
++#TRADE_BRIEFS_PER_HOUR=40
++#TRADE_BRIEFS_INTERVAL_MS=600000
++# One market-wide brief a UTC day at most: off with TRADE_MARKET_BRIEFS=off, due checked this often.
++#TRADE_MARKET_BRIEFS=on
++#TRADE_MARKET_BRIEFS_INTERVAL_MS=600000
++
# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
#EVENTS_URL=http://127.0.0.1:4300
# Signing secret for Events deliveries to /internal/events (32+ chars).
ddiff --git a/README.md b/README.md
iindex 8e66caa..4c4e706 100644
---- a/README.md
++++ b/README.md
@@@ -143,13 +143,23 @@ Trade shows only what a source stated:
- text that reads as advice is refused, and so is a placeholder run;
- shown labelled "AI-written, not reviewed by a person". They do not make a page indexable: that still needs a
person-reviewed context.
++- **Market brief** (`domain/market-briefs.js`, the same OpenVibe.AI `trade.summarize_market_context` workflow): one
++ a day at most (a UTC day, `TRADE_MARKET_BRIEFS=off` turns it off), written only from what Trade stored — the day's
++ filings by form, the euro reference rates, the companies with new filings or SEC figures and aggregate watchlist
++ activity. Every kept point links to its Trade page; no prices are sent. Each brief has its own page
++ `/market/<YYYY-MM-DD>`: unreviewed it carries "AI-written, not yet reviewed by a person" and is noindex through the
++ shared gate; once an editor approves it from `GET /api/v1/review-queue` (kind `market_brief`) the page is indexable
++ and enters the pages sitemap. The home never changes indexability because of a brief: it shows the latest reviewed
++ one in full (with its date), or a one-line link to the unreviewed page while none is reviewed. Without OpenVibe.AI
++ nothing is made and nothing renders.
- **No prices:** no price feed is configured.
### Routes (server-rendered, useful without JavaScript)
| Route | What |
|---|---|
--| `/` | the front page (`server/render/home.js`): a company search and the site's numbers, the latest AI briefs, euro reference rates (each currency's newest rate, its date and source, and the change from the same source's previous rate), new 10-K/10-Q reports, the last 7 days' filings by form (plain names), the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD); `/?page=N` answers 301 to `/instruments?page=N` |
++| `/` | the front page (`server/render/home.js`): a company search and the site's numbers, the latest reviewed daily market brief (or a link to the unreviewed one), the latest AI briefs, euro reference rates (each currency's newest rate, its date and source, and the change from the same source's previous rate), new 10-K/10-Q reports, the last 7 days' filings by form (plain names), the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD); `/?page=N` answers 301 to `/instruments?page=N` |
++| `/market/<YYYY-MM-DD>` | one market brief: unreviewed it is noindex and labelled; a person's approval makes it indexable |
| `/instruments` | every instrument, the newest filing or figure first (the front page's old list), 50 a page |
| `/filings`, `/filings?form=` | the newest SEC filings, 50 a page, with the last 7 days' counts by form; one form's list is noindex with `/filings` as its canonical |
| `/resolve?q=` | resolution; one match → 302 to the instrument |
ddiff --git a/migrations/0007_market_briefs.sql b/migrations/0007_market_briefs.sql
nnew file mode 100644
iindex 0000000..b79d3bd
---- /dev/null
++++ b/migrations/0007_market_briefs.sql
@@@ -0,0 +1,39 @@
++-- phase: expand
++-- A market-wide AI brief (server/domain/market-briefs.js): one a day at most, written by OpenVibe.AI from what Trade
++-- already holds — the day's filings by form, the euro reference rates, the companies with new filings or SEC figures,
++-- and aggregate watchlist activity. It cites Trade's own pages and keeps the home page noindex until a person
++-- reviews it; reviews are append-only, like trade_context_reviews.
++CREATE TABLE IF NOT EXISTS trade_market_briefs (
++ id text COLLATE "C" PRIMARY KEY, -- tmk_<ULID>
++ brief_date text COLLATE "C" NOT NULL UNIQUE, -- the UTC day it covers, YYYY-MM-DD: at most one a day
++ summary text NOT NULL,
++ points jsonb NOT NULL DEFAULT '[]', -- [{ text, cites: [{ url, label }] }]
++ cites jsonb NOT NULL DEFAULT '[]', -- [{ url, label }] every page the brief rests on
++ gaps jsonb NOT NULL DEFAULT '[]',
++ as_of bigint, -- the newest cited row's time
++ inputs_hash text NOT NULL, -- what it was written from
++ run_id text,
++ model text,
++ trigger text NOT NULL DEFAULT 'schedule',
++ created_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS trade_market_briefs_day ON trade_market_briefs (brief_date DESC);
++
++CREATE TABLE IF NOT EXISTS trade_market_brief_reviews (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ brief_id text COLLATE "C" NOT NULL REFERENCES trade_market_briefs(id),
++ reviewer text NOT NULL, -- usr_… only: a person, never a service or the AI
++ decision text NOT NULL CHECK (decision IN ('approved','rejected')),
++ note text,
++ reviewed_at bigint NOT NULL
++);
++CREATE INDEX IF NOT EXISTS trade_market_brief_reviews_brief ON trade_market_brief_reviews (brief_id, id);
++CREATE OR REPLACE FUNCTION trade_market_brief_reviews_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ RAISE EXCEPTION 'trade_market_brief_reviews rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++CREATE OR REPLACE TRIGGER trade_market_brief_reviews_no_update BEFORE UPDATE ON trade_market_brief_reviews FOR EACH ROW EXECUTE FUNCTION trade_market_brief_reviews_guard();
ddiff --git a/public/css/trade.css b/public/css/trade.css
iindex 4cf3408..ea77ba3 100644
---- a/public/css/trade.css
++++ b/public/css/trade.css
@@@ -85,6 +85,7 @@ form.inline button { margin: 0; padding: 2px 10px; font-size: .85rem; }
.tr-more { font-weight: 600; font-size: .95rem; text-decoration: none; }
.tr-more:hover { text-decoration: underline; text-underline-offset: 3px; }
.tr-lede { margin: 4px 0 14px; color: var(--text-secondary, #96a7c2); }
++.tr-market-teaser { margin: 24px 0 0; font-weight: 600; }
.tr-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); }
.tr-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); overflow-wrap: anywhere; }
.tr-card p { margin: 0; }
ddiff --git a/server/app.js b/server/app.js
iindex c5b33cd..957ae4a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -104,6 +104,8 @@ async function createApp(opts = {}) {
ctx.facts = require('./domain/facts').createFacts({ store, ctx, sec: ctx.sec, log });
ctx.ai = opts.ai || require('./clients/ai').createAi({ config, fetchImpl });
ctx.briefs = require('./domain/briefs').createBriefs({ store, config, ctx, ai: ctx.ai, log });
++ // The market-wide brief (domain/market-briefs.js), through the same OpenVibe.AI seam.
++ ctx.marketBriefs = require('./domain/market-briefs').createMarketBriefs({ store, config, ctx, ai: ctx.ai, log });
// Sign-in with OpenVibe.Network (openvibe-sdk/sso): routes, offline session verification through the
// shared JWKS client, state + PKCE. ctx.jwks is the same client viewer.js verifies service tokens with.
ctx.jwksUrl = `${String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '')}/api/.well-known/jwks`;
@@@ -118,7 +120,7 @@ async function createApp(opts = {}) {
ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)
ctx.viewers = createViewerResolver({ auth: ctx.auth, config, jwksUrl: ctx.jwksUrl });
ctx.common = createCommon({ config, store });
-- ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, facts: ctx.facts, briefs: ctx.briefs, sec: ctx.sec, log });
++ ctx.worker = createWorker({ config, sync: ctx.sync, freshness: ctx.freshness, indexing: ctx.indexing, outbox: ctx.outbox, facts: ctx.facts, briefs: ctx.briefs, marketBriefs: ctx.marketBriefs, sec: ctx.sec, log });
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
ddiff --git a/server/config.js b/server/config.js
iindex 63a5f56..eeb7ca9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -122,6 +122,12 @@ function load(env = process.env) {
perTick: Math.max(int(env.TRADE_BRIEFS_PER_TICK, 6), 0),
perHour: Math.max(int(env.TRADE_BRIEFS_PER_HOUR, 40), 0),
},
++ // The market-wide brief (domain/market-briefs.js): one UTC day at most, from the day's filings, euro rates and
++ // companies with new filings or figures. Off when TRADE_MARKET_BRIEFS=off, and always off without OpenVibe.AI.
++ marketBriefs: {
++ enabled: env.TRADE_MARKET_BRIEFS !== 'off',
++ intervalMs: Math.max(int(env.TRADE_MARKET_BRIEFS_INTERVAL_MS, 10 * 60_000), 10_000),
++ },
// Browser origins that may call /api/v1 with a Bearer Network JWT (no cookies cross origins).
apiCorsOrigins: list(env.API_CORS_ORIGINS || 'https://openvibe.network'),
ddiff --git a/server/domain/market-briefs.js b/server/domain/market-briefs.js
nnew file mode 100644
iindex 0000000..206ce17
---- /dev/null
++++ b/server/domain/market-briefs.js
@@@ -0,0 +1,351 @@
++'use strict';
++
++/**
++ * The market brief: one AI-written, market-wide summary a day, from what Trade already holds —
++ *
++ * the day's filings form type → how many were published or first seen in the last 24 hours, each to
++ * /filings?form=… (the same query the page lists)
++ * the euro reference rates
++ * each currency's newest rate and the previous one from the same source, to its page
++ * the companies with new filings or SEC figures
++ * one source per company, with what arrived, to /i/SYMBOL
++ * watchlist activity aggregate additions only, when there are any; no owner or watchlist name leaves Trade
++ * nothing else no prices (Trade has no licensed price data), no reader data
++ *
++ * OpenVibe.AI writes it through the same seam and workflow as the per-instrument briefs (clients/ai.js,
++ * trade.summarize_market_context). Every point must cite a Trade page (a point without a link is dropped and listed
++ * as a gap), and the text is refused if it reads as advice. One brief per UTC day at most: the daily cap is the
++ * brief_date unique key, so a restart or a reader never makes a second AI call that day.
++ *
++ * The brief is AI-written until an editor approves it (trade_market_brief_reviews, append-only). Each brief has its
++ * own page at /market/<YYYY-MM-DD>, whose own indexability runs through the shared gate
++ * (openvibe-publishing/seo) with the brief's authorship: unreviewed → noindex, reviewed → indexable. The home never
++ * changes indexability because of a brief: it shows the latest reviewed one in full and, while none is reviewed,
++ * one line linking to the unreviewed page. Without OpenVibe.AI (no URL or no client secret) nothing is made, nothing
++ * is shown and nothing fails.
++ */
++const crypto = require('crypto');
++const { ADVICE_RE } = require('./context');
++const { newId, iso } = require('./helpers');
++const { ApiError } = require('../http/errors');
++
++const DAY = 24 * 3600 * 1000;
++const MAX_SOURCES = 50;
++const MAX_FORMS = 8;
++const MAX_INSTRUMENTS = 12;
++const SOURCE_NAME = { 'ecb-euro-reference-rates': 'European Central Bank', 'frankfurter-eur-rates': 'Frankfurter (ECB data)' };
++
++function createMarketBriefs({ store, config, ctx, ai, log = console }) {
++ const { db } = store;
++ const enabled = Boolean(config.marketBriefs.enabled && ai.enabled);
++
++ const q = {
++ byDate: db.prepare('SELECT * FROM trade_market_briefs WHERE brief_date = ?'),
++ byId: db.prepare('SELECT * FROM trade_market_briefs WHERE id = ?'),
++ latest: db.prepare('SELECT * FROM trade_market_briefs ORDER BY brief_date DESC, created_at DESC, id DESC LIMIT 1'),
++ latestReviewed: db.prepare(`SELECT b.* FROM trade_market_briefs b JOIN trade_market_brief_reviews r ON r.brief_id = b.id
++ WHERE r.decision = 'approved' ORDER BY b.brief_date DESC, b.created_at DESC, b.id DESC LIMIT 1`),
++ latestUnreviewed: db.prepare(`SELECT b.* FROM trade_market_briefs b LEFT JOIN trade_market_brief_reviews r ON r.brief_id = b.id
++ WHERE r.id IS NULL ORDER BY b.brief_date DESC, b.created_at DESC, b.id DESC LIMIT 1`),
++ // For the pages sitemap: the reviewed briefs' own /market/<date> pages, no review lookup per row.
++ reviewed: db.prepare(`SELECT b.brief_date, b.created_at FROM trade_market_briefs b JOIN trade_market_brief_reviews r ON r.brief_id = b.id
++ WHERE r.decision = 'approved' ORDER BY b.brief_date DESC LIMIT ?`),
++ latestReview: db.prepare('SELECT * FROM trade_market_brief_reviews WHERE brief_id = ? ORDER BY id DESC LIMIT 1'),
++ insert: db.prepare(`INSERT INTO trade_market_briefs (id, brief_date, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
++ VALUES (@id, @brief_date, @summary, @points, @cites, @gaps, @as_of, @inputs_hash, @run_id, @model, @trigger, @created_at)`),
++ insertReview: db.prepare(`INSERT INTO trade_market_brief_reviews (brief_id, reviewer, decision, note, reviewed_at)
++ VALUES (@brief_id, @reviewer, @decision, @note, @reviewed_at)`),
++ // The companies with a filing or a newly read SEC figure in the window: one source each, the newest first.
++ instruments: db.prepare(`SELECT * FROM (
++ SELECT i.id, i.symbol, i.name,
++ (SELECT COUNT(*) FROM trade_source_documents d
++ WHERE d.instrument_id = i.id AND d.removed_at IS NULL
++ AND COALESCE(d.published_at, d.first_seen_at) > @since) AS docs,
++ (SELECT string_agg(DISTINCT d.form_type, ', ' ORDER BY d.form_type) FROM trade_source_documents d
++ WHERE d.instrument_id = i.id AND d.removed_at IS NULL AND d.form_type IS NOT NULL
++ AND COALESCE(d.published_at, d.first_seen_at) > @since) AS forms,
++ (SELECT COUNT(*) FROM trade_market_observations o
++ WHERE o.instrument_id = i.id AND o.source_key = 'sec-companyfacts' AND o.recorded_at > @since) AS facts,
++ (SELECT MAX(o.recorded_at) FROM trade_market_observations o
++ WHERE o.instrument_id = i.id AND o.source_key = 'sec-companyfacts' AND o.recorded_at > @since) AS newest_fact,
++ (SELECT MAX(COALESCE(d.published_at, d.first_seen_at)) FROM trade_source_documents d
++ WHERE d.instrument_id = i.id AND d.removed_at IS NULL
++ AND COALESCE(d.published_at, d.first_seen_at) > @since) AS newest_doc
++ FROM trade_instruments i
++ WHERE i.status = 'active' AND i.kind <> 'currency') x
++ WHERE x.docs > 0 OR x.facts > 0
++ ORDER BY GREATEST(COALESCE(x.newest_doc, 0), COALESCE(x.newest_fact, 0)) DESC, x.symbol
++ LIMIT @limit`),
++ watchlistAdds: db.prepare('SELECT COUNT(*) AS n FROM trade_watchlist_items WHERE added_at > ?'),
++ };
++
++ const dayOf = (t) => new Date(Number(t)).toISOString().slice(0, 10);
++ const day = (t) => (t == null ? 'no date' : new Date(Number(t)).toISOString().slice(0, 10));
++ const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
++
++ const MAJOR = ['USD', 'GBP', 'JPY', 'CHF', 'CNY', 'CAD', 'AUD', 'INR', 'KRW', 'MXN', 'BRL', 'SEK'];
++ const rateRank = (r) => (MAJOR.includes(r.symbol) ? MAJOR.indexOf(r.symbol) : MAJOR.length);
++
++ /**
++ * The numbered sources OpenVibe.AI reads and, for each, the Trade page a claim about it links to.
++ * → { sources, refs, hash, asOf }. Only stored rows: no fetch here.
++ */
++ async function inputsFor(now) {
++ const since = now - DAY;
++ const sources = [];
++ const refs = [];
++ const add = (source, ref) => { sources.push(source); refs.push(ref); };
++
++ for (const f of (await ctx.documents.formCounts(since)).slice(0, MAX_FORMS)) {
++ if (sources.length >= MAX_SOURCES) break;
++ const url = `/filings?form=${encodeURIComponent(f.form_type)}`;
++ add({
++ source_type: 'trade.filings_by_form',
++ source_id: `form:${f.form_type}`,
++ title: `${f.form_type} filings in the last 24 hours`,
++ retrieved_at: iso(now),
++ url,
++ snippet: `${f.count} ${f.form_type} filings were published or first seen in the last 24 hours.`,
++ provenance: { service: 'trade', source_key: 'trade.documents' },
++ }, { url, label: `${f.form_type} filings`, at: now });
++ }
++
++ const rates = (await ctx.observations.rates()).sort((a, b) => rateRank(a) - rateRank(b) || a.symbol.localeCompare(b.symbol));
++ for (const r of rates) {
++ if (sources.length >= MAX_SOURCES) break;
++ const latest = r.latest;
++ const url = ctx.urls.path.instrument({ symbol: r.symbol });
++ const content = `1 EUR = ${latest.value} ${r.symbol} on ${day(latest.observed_at)}${r.previous ? `, from ${r.previous.value} on ${day(r.previous.observed_at)}` : ''} (${SOURCE_NAME[latest.source_key] || latest.source_key}).`;
++ add({
++ source_type: 'trade.rate',
++ source_id: `rate:${r.symbol}`,
++ title: `EUR/${r.symbol} reference rate`,
++ observed_at: iso(latest.observed_at),
++ retrieved_at: iso(now),
++ url,
++ snippet: content,
++ provenance: { service: 'trade', source_key: latest.source_key },
++ }, { url, label: `EUR/${r.symbol}`, at: latest.observed_at });
++ }
++
++ for (const i of await q.instruments.all({ since, limit: MAX_INSTRUMENTS })) {
++ if (sources.length >= MAX_SOURCES) break;
++ const parts = [];
++ if (Number(i.docs) > 0) parts.push(`${i.docs} new filing${Number(i.docs) === 1 ? '' : 's'}${i.forms ? ` (${i.forms})` : ''}`);
++ if (Number(i.facts) > 0) parts.push(`${Number(i.facts)} new SEC figure${Number(i.facts) === 1 ? '' : 's'}`);
++ const url = ctx.urls.path.instrument(i);
++ const newest = Math.max(Number(i.newest_doc) || 0, Number(i.newest_fact) || 0, since);
++ add({
++ source_type: 'trade.instrument',
++ source_id: `instrument:${i.id}`,
++ title: `${i.symbol} — ${i.name}`,
++ retrieved_at: iso(now),
++ url,
++ snippet: `${i.symbol}: ${parts.join(' and ')} in the last 24 hours.`,
++ provenance: { service: 'trade', source_key: 'trade.instruments' },
++ }, { url, label: i.symbol, at: newest });
++ }
++
++ // Aggregate only: a count can be a market signal, an owner or a watchlist name never leaves Trade.
++ const adds = Number((await q.watchlistAdds.get(since)).n);
++ if (adds > 0 && sources.length < MAX_SOURCES) {
++ add({
++ source_type: 'trade.watchlists',
++ source_id: 'watchlist-activity',
++ title: 'Watchlist activity in the last 24 hours',
++ retrieved_at: iso(now),
++ url: '/watchlists',
++ snippet: `${adds} instrument${adds === 1 ? ' was' : 's were'} added to people's private watchlists in the last 24 hours (aggregate only; no owner or watchlist name is included).`,
++ provenance: { service: 'trade', source_key: 'trade.watchlists' },
++ }, { url: '/watchlists', label: 'watchlist activity', at: now });
++ }
++
++ const hash = crypto.createHash('sha256').update(JSON.stringify(sources.map((s) => [s.source_id, s.snippet]))).digest('hex');
++ const asOf = refs.reduce((a, r) => (r.at != null && (a == null || r.at > a) ? r.at : a), null);
++ return { sources, refs, hash, asOf };
++ }
++
++ /** → the public shape, with the person's latest review if there is one. */
++ async function shape(row) {
++ if (!row) return null;
++ const review = await q.latestReview.get(row.id);
++ const reviewed = Boolean(review && review.decision === 'approved' && /^usr_/.test(String(review.reviewer)));
++ const rejected = Boolean(review && review.decision === 'rejected');
++ return {
++ id: row.id,
++ date: row.brief_date,
++ summary: row.summary,
++ points: parse(row.points, []),
++ cites: parse(row.cites, []),
++ gaps: parse(row.gaps, []),
++ as_of: iso(row.as_of),
++ created_at: iso(row.created_at),
++ model: row.model || null,
++ status: reviewed ? 'reviewed' : rejected ? 'rejected' : 'unreviewed',
++ reviewed,
++ authorship: { kind: 'ai', reviewed, workflow: ai.WORKFLOW, run_id: row.run_id || null },
++ disclaimer: 'Written by OpenVibe.AI from what OpenVibe.Trade recorded in the last 24 hours. Informational only; not investment advice.',
++ };
++ }
++
++ /**
++ * Write today's brief, at most once a UTC day. → { brief, created }; brief null when OpenVibe.AI is off or there
++ * is nothing stored to write from. Throws ApiError: 422 advice refused, 502/503 OpenVibe.AI (the worker catches
++ * these; a page never calls this).
++ */
++ async function make({ trigger = 'schedule' } = {}) {
++ if (!enabled) return { brief: null, created: false };
++ const now = store.now();
++ const existing = await q.byDate.get(dayOf(now));
++ if (existing) return { brief: await shape(existing), created: false };
++
++ const { sources, refs, hash, asOf } = await inputsFor(now);
++ if (!sources.length) return { brief: null, created: false };
++
++ let r;
++ try {
++ // The same workflow as an instrument brief: a market-wide "instrument" so the model knows the scale, and
++ // the day's rows as its numbered sources.
++ r = await ai.run({
++ instrument: { symbol: 'MARKET', name: 'The US market: every company Trade covers' },
++ stale_after_hours: 24,
++ sources,
++ });
++ } catch (err) {
++ throw new ApiError(err.status || 502, err.code || 'ai.error', `No market brief: ${err.message}`);
++ }
++ if (r.stub) throw new ApiError(503, 'market_brief.stub', 'OpenVibe.AI answered with a placeholder (no model is configured); no market brief was made');
++ const out = r.output || {};
++ const gaps = Array.isArray(out.gaps) ? out.gaps.map((g) => String(g).slice(0, 300)).slice(0, 20) : [];
++
++ const cite = (list) => {
++ const seen = new Set();
++ const got = [];
++ for (const n of Array.isArray(list) ? list : []) {
++ if (!Number.isInteger(n) || n < 0 || n >= refs.length) continue;
++ const ref = refs[n];
++ if (seen.has(ref.url)) continue;
++ seen.add(ref.url);
++ got.push({ url: ref.url, label: ref.label });
++ }
++ return got;
++ };
++
++ const summary = String(out.summary || '').trim().slice(0, 3000);
++ const points = [];
++ for (const p of Array.isArray(out.observations) ? out.observations : []) {
++ const text = String(p && p.text || '').trim().slice(0, 600);
++ if (!text) continue;
++ const cites = cite(p.citations);
++ if (!cites.length) { gaps.push(`Left out (no source link): ${text.slice(0, 200)}`); continue; }
++ points.push({ text, cites });
++ }
++ if (!summary && !points.length) throw new ApiError(502, 'market_brief.empty', 'OpenVibe.AI returned no text it could support from the sources');
++ if (ADVICE_RE.test(summary) || points.some((p) => ADVICE_RE.test(p.text))) {
++ log.warn('[Trade] market brief refused: it read as advice');
++ throw new ApiError(422, 'market_brief.advice_refused', 'The AI text read as investment advice, so it was not kept (Trade publishes information only)');
++ }
++ const cites = [];
++ const seen = new Set();
++ for (const c of cite(out.citations).concat(points.flatMap((p) => p.cites))) {
++ if (seen.has(c.url)) continue;
++ seen.add(c.url);
++ cites.push(c);
++ }
++ if (!cites.length) throw new ApiError(502, 'market_brief.unsourced', 'OpenVibe.AI returned a brief whose claims do not link to any Trade page');
++
++ await q.insert.run({
++ id: newId('tmk', now), brief_date: dayOf(now), summary, points: JSON.stringify(points), cites: JSON.stringify(cites),
++ gaps: JSON.stringify(gaps.slice(0, 20)), as_of: asOf, inputs_hash: hash, run_id: r.runId, model: r.model,
++ trigger, created_at: now,
++ });
++ return { brief: await shape(await q.byDate.get(dayOf(now))), created: true };
++ }
++
++ /** Make today's brief if it is due; never throws (the worker calls this). */
++ async function tick() {
++ if (!enabled) return null;
++ try {
++ return await make({ trigger: 'schedule' });
++ } catch (err) {
++ log.warn(`[Trade] market brief not made: ${err.message}`);
++ return null;
++ }
++ }
++
++ /** The latest brief a reader may see: none without OpenVibe.AI, and none while the latest was rejected. */
++ async function latest() {
++ if (!enabled) return null;
++ const row = await q.latest.get();
++ if (!row) return null;
++ const brief = await shape(row);
++ return brief.status === 'rejected' ? null : brief;
++ }
++
++ /** The latest brief a person approved: the only one the home shows in full. */
++ async function latestReviewed() {
++ if (!enabled) return null;
++ const row = await q.latestReviewed.get();
++ if (!row) return null;
++ const brief = await shape(row);
++ return brief.reviewed ? brief : null;
++ }
++
++ /** The latest brief still waiting for a person: the home links to it but never shows it. */
++ async function latestUnreviewed() {
++ if (!enabled) return null;
++ const row = await q.latestUnreviewed.get();
++ return row ? await shape(row) : null;
++ }
++
++ /** One brief by its UTC date, for /market/<YYYY-MM-DD>; rejected briefs are not shown. */
++ async function get(date) {
++ if (!enabled) return null;
++ const row = await q.byDate.get(String(date || ''));
++ if (!row) return null;
++ const brief = await shape(row);
++ return brief.status === 'rejected' ? null : brief;
++ }
++
++ /** The reviewed brief pages for the pages sitemap: [{ date, created_at }], newest first. */
++ async function reviewed(limit = 100) {
++ if (!enabled) return [];
++ const n = Math.min(Math.max(Number(limit) || 100, 1), 500);
++ return (await q.reviewed.all(n)).map((r) => ({ date: r.brief_date, created_at: iso(r.created_at) }));
++ }
++
++ /** The gate facts for a brief's own page: unreviewed AI content is noindex. */
++ const gateFacts = (brief) => (brief ? { authorship: { mode: 'ai', reviewed: brief.reviewed } } : {});
++
++ /** The market brief waiting for a person, for GET /api/v1/review-queue. Only the latest one is shown. */
++ async function pendingReviews() {
++ if (!enabled) return [];
++ const row = await q.latest.get();
++ if (!row || await q.latestReview.get(row.id)) return [];
++ return [{
++ site: 'trade', kind: 'market_brief', id: row.id,
++ title: `Market brief, ${row.brief_date}`,
++ revision: 1, state: 'draft',
++ url: `/market/${row.brief_date}`, editUrl: `/market/${row.brief_date}`,
++ createdAt: iso(row.created_at),
++ reasons: [{ code: 'ai_generated_unreviewed', effect: 'noindex', detail: 'the market brief is an AI draft', text: 'AI-written and not yet reviewed by a person.' }],
++ review: { method: 'POST', path: `/api/v1/market-briefs/${encodeURIComponent(row.id)}/review` },
++ }];
++ }
++
++ /** Record a person's review of one brief. → the brief as shaped. */
++ async function review(viewer, id, { decision, note = null } = {}) {
++ if (decision !== 'approved' && decision !== 'rejected') throw new ApiError(422, 'review.invalid', 'decision must be approved or rejected');
++ const reviewer = viewer && viewer.subject;
++ if (!/^usr_/.test(String(reviewer))) throw new ApiError(403, 'review.person_only', 'Only a person can review an AI-written brief');
++ const row = await q.byId.get(String(id || ''));
++ if (!row) throw new ApiError(404, 'market_brief.not_found', 'No such market brief');
++ if (await q.latestReview.get(row.id)) throw new ApiError(409, 'review.already', 'This market brief has already been reviewed');
++ await q.insertReview.run({ brief_id: row.id, reviewer, decision, note: note == null ? null : String(note).slice(0, 2000), reviewed_at: store.now() });
++ return await shape(row);
++ }
++
++ return { enabled, WORKFLOW: ai.WORKFLOW, make, tick, latest, latestReviewed, latestUnreviewed, get, reviewed, gateFacts, pendingReviews, review, inputsFor };
++}
++
++module.exports = { createMarketBriefs };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex aefc8e1..3fb3eb3 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -102,12 +102,21 @@ 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.
++ // The reviewer queue: company pages whose AI context draft waits for a person, and the market brief while it is
++ // unreviewed. 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() };
++ const items = (await context.pendingReviews()).concat(await ctx.marketBriefs.pendingReviews());
++ return { items: items.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)) };
}));
++ // The market brief's review: approving it lets the home page be indexed and changes its label.
++ define(router, 'post', '/market-briefs/:id/review', 'reviewMarketBrief', B('trade.context.publish'), jsonBody, run(async (req) => {
++ if (!(req.viewer.kind === 'user' && req.viewer.editor)) throw new ApiError(403, 'editor.forbidden', 'Only Trade editors review the publication queue');
++ const b = req.body || {};
++ return { brief: await ctx.marketBriefs.review(req.viewer, req.params.id, { decision: b.decision, note: b.note }) };
++ }, 201));
++
// ── Instruments ─────────────────────────────────────────
define(router, 'get', '/instruments', 'getInstruments', run(async (req) => {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 583d8ec..6087164 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -68,7 +68,7 @@ function legalPages() {
}
function createDiscovery(ctx) {
-- const { config, store, instruments, documents, indexing, context, urls, common } = ctx;
++ const { config, store, instruments, documents, indexing, context, marketBriefs, urls, common } = ctx;
const router = express.Router();
const abs = urls.abs;
const xml = (res, body, type = 'application/xml') => res.type(type).set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
@@@ -187,13 +187,20 @@ function createDiscovery(ctx) {
]));
});
-- // The pages a person browses: the front page, every company and currency, the filings. Their lastmod is the
-- // newest filing's (they list it). /sources is public but noindex, so it never enters the sitemap.
++ // The pages a person browses: the front page, every company and currency, the filings, and each reviewed market
++ // brief's own page. Their lastmod is the newest filing's (they list it). /sources is public but noindex, so it
++ // never enters the sitemap. The home never depends on a brief: an unreviewed one is noindex on its own page.
define(router, 'get', '/sitemaps/pages.xml', 'pagesSitemap', async (_req, res) => {
-- const newest = (await documents.recent(1))[0];
++ const [newestDoc, briefs] = await Promise.all([documents.recent(1), marketBriefs.reviewed(100)]);
++ const newest = newestDoc[0];
const lastmod = newest ? Math.max(newest.first_seen_at, newest.published_at || 0) : null;
const decide = (path) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0 }, { policy: { minWords: 0 } });
-- xml(res, seo.sitemap(PUBLIC_PAGES.filter((p) => p.sitemap).map((p) => ({ loc: abs(p.path), lastmod, decision: decide(p.path) }))).files[0]);
++ const entries = PUBLIC_PAGES.filter((p) => p.sitemap).map((p) => ({ loc: abs(p.path), lastmod, decision: decide(p.path) }));
++ for (const b of briefs) {
++ const loc = abs(`/market/${b.date}`);
++ entries.push({ loc, ...(b.created_at ? { lastmod: b.created_at } : {}), decision: decide(`/market/${b.date}`) });
++ }
++ xml(res, seo.sitemap(entries).files[0]);
});
define(router, 'get', '/sitemaps/instruments.xml', 'instrumentSitemap', async (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5115ca7..2eff459 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -68,8 +68,9 @@ function createPages(ctx) {
const p = parseInt(req.query.page, 10);
if (p > 1) { common.vary(res); res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })); return res.redirect(301, `/instruments?page=${p}`); }
const now = ctx.store.now();
-- const [kinds, docStats, obsStats, briefStats, briefs, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([
++ const [kinds, docStats, obsStats, briefStats, briefs, marketBrief, marketBriefPending, rateRows, reportDocs, forms, recentDocs, movers] = await Promise.all([
instruments.countByKind(), documents.stats(now - DAY), observations.stats(), ctx.briefs.stats(), ctx.briefs.recent(6),
++ ctx.marketBriefs.latestReviewed(), ctx.marketBriefs.latestUnreviewed(),
observations.rates(), documents.recentOf(['10-K', '10-Q', '20-F', '40-F'], { limit: 8 }), documents.formCounts(now - 7 * DAY),
documents.recent(8), instruments.page({ limit: 8, offset: 0 }),
]);
@@@ -80,12 +81,14 @@ function createPages(ctx) {
companies: (kinds.equity || 0) + (kinds.fund || 0), currencies: kinds.currency || 0, instruments: Object.values(kinds).reduce((a, b) => a + b, 0),
filings: docStats.total, filingsDay: docStats.day, figureCompanies: obsStats.companies, briefs: briefStats.total,
},
++ // The home shows only a reviewed brief; while none is reviewed it links to the unreviewed one's page.
++ marketBrief, marketBriefPending: marketBrief ? null : marketBriefPending,
briefs, rates, reports: await withInstruments(reportDocs), forms, recent: await withInstruments(recentDocs),
movers: { instruments: movers.instruments, ...await headlines(movers.instruments, now) }, sync: await sync.state(), faq,
};
common.page(req, res, {
title: 'SEC filings, company figures, AI briefs', canonical: urls.abs('/'), decision: listing('/'),
-- description: 'SEC filings as they arrive, the figures inside them, euro reference rates and AI briefs that cite every number, for about 8,000 US companies. Information only.',
++ description: 'SEC filings as they arrive, the figures inside them, euro reference rates, a daily market brief and AI briefs that cite every number, for about 8,000 US companies. Information only.',
// The home page carries the site's ai-summary meta and WebPage JSON-LD (v1.3.0 forwards it).
summary: SITE_SUMMARY, styles: [showcase.STYLESHEET], bodyClass: 'home', feeds: FEEDS,
jsonLd: [
@@@ -96,6 +99,20 @@ function createPages(ctx) {
});
});
++ // One market brief's own page. Its indexability is the brief's alone: unreviewed AI content is noindex through
++ // the same publishing gate as instrument context; a person's approval makes it indexable.
++ define(router, 'get', '/market/:date', 'showMarketBrief', async (req, res) => {
++ const brief = /^\d{4}-\d{2}-\d{2}$/.test(String(req.params.date)) ? await ctx.marketBriefs.get(req.params.date) : null;
++ if (!brief) return common.notFound(req, res);
++ const canonical = urls.abs(`/market/${brief.date}`);
++ common.page(req, res, {
++ title: `Market brief, ${brief.date}`, canonical,
++ decision: seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: canonical, wordCount: 0, ...ctx.marketBriefs.gateFacts(brief) }, { policy: { minWords: 0 } }),
++ description: `What changed across the US market on ${brief.date}, from the filings, figures and euro reference rates on OpenVibe.Trade. Written by OpenVibe.AI.`,
++ body: home.marketBriefPage(brief),
++ });
++ });
++
define(router, 'get', '/instruments', 'showInstruments', async (req, res) => {
const pageNo = Math.max(parseInt(req.query.page, 10) || 1, 1);
const per = 50;
ddiff --git a/server/render/home.js b/server/render/home.js
iindex 50762a6..7f9ff3b 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -130,10 +130,52 @@ function homePanes(d, urls) {
].filter((p) => p.html);
}
++/** The AI label a market brief carries: reviewed only after a person approved it in the queue. */
++const marketBriefLabel = (b) => (b.reviewed ? 'AI-written, reviewed by a person' : 'AI-written, not yet reviewed by a person');
++
++/** A brief's text, every point linked to the Trade page it rests on, its sources and its gaps. */
++function marketBriefBody(b) {
++ const cites = (list) => list.map((c) => html`<a class="cite" href="${c.url}">${c.label}</a>`);
++ return html`${b.summary ? html`<p>${b.summary}</p>` : ''}
++${b.points.length ? html`<ul>${b.points.map((p) => html`<li>${p.text} ${cites(p.cites)}</li>`)}</ul>` : ''}
++${b.cites.length ? html`<p class="muted tr-small">Sources: ${b.cites.map((c) => html`<a href="${c.url}">${c.label}</a>`)}</p>` : ''}
++<p class="muted">${b.disclaimer}${b.model ? html` (${b.model})` : ''}</p>
++${b.gaps.length ? html`<details><summary>What the sources did not support</summary><ul>${b.gaps.map((g) => html`<li>${g}</li>`)}</ul></details>` : ''}`;
++}
++
++/**
++ * The latest reviewed market brief on the home, in full. Only a person-approved brief is shown here; an unreviewed
++ * one is a one-line link (below) and its own noindex page.
++ */
++function marketBriefSection(b) {
++ if (!b) return '';
++ return html`<section class="tr-sec ai-brief" id="market-brief" aria-labelledby="market-brief-h">
++<div class="tr-head"><h2 id="market-brief-h">Market brief <span class="badge">${marketBriefLabel(b)}</span></h2><a class="tr-more" href="/market/${b.date}">Permalink</a></div>
++<p class="tr-lede">What changed across the market in the last 24 hours, in plain words, from the filings, figures and euro reference rates on OpenVibe.Trade. <time datetime="${b.date}">${b.date}</time>.</p>
++${marketBriefBody(b)}
++</section>`;
++}
++
++/** No reviewed brief yet: the home links to the unreviewed one's own (noindex) page, and shows no AI text. */
++function marketBriefTeaser(b) {
++ if (!b) return '';
++ return html`<p class="tr-market-teaser"><a href="/market/${b.date}">Today's market brief (AI-written, not yet reviewed) <span aria-hidden="true">→</span></a></p>`;
++}
++
++/** One brief's own page: /market/<YYYY-MM-DD>, with the gate's noindex until a person reviews it. */
++function marketBriefPage(b) {
++ return html`<p class="muted"><a href="/">OpenVibe.Trade</a> · Market brief</p>
++<h1>Market brief, <time datetime="${b.date}">${b.date}</time> <span class="badge">${marketBriefLabel(b)}</span></h1>
++<p class="tr-lede">What changed across the market in the last 24 hours, in plain words, from the filings, figures and euro reference rates on OpenVibe.Trade.</p>
++${marketBriefBody(b)}
++<p class="muted"><a href="/">Back to OpenVibe.Trade</a></p>`;
++}
++
/**
* d: { stats: { companies, currencies, filings, filingsDay, filers, figures, figureCompanies, briefs },
-- * briefs, rates: [{ symbol, name, latest, previous, dto }], reports, recent: [{ document, instrument }],
-- * forms: [{ form_type, count }], movers: { instruments, latest, latestDocs }, sync, faq }
++ * marketBrief, marketBriefPending, briefs, rates: [{ symbol, name, latest, previous, dto }], reports,
++ * recent: [{ document, instrument }], forms: [{ form_type, count }],
++ * movers: { instruments, latest, latestDocs }, sync, faq }
*/
function homePage(d, urls) {
const s = d.stats;
@@@ -154,6 +196,7 @@ function homePage(d, urls) {
return html`${hero}
<p class="muted">What this site is not: there is no order entry, no brokerage, no custody of money or assets, no marketplace and no personal recommendation here.</p>
++${raw(marketBriefSection(d.marketBrief) || marketBriefTeaser(d.marketBriefPending))}
${raw(pickBlock({
key: 'data',
label: 'The market, by kind',
@@@ -178,4 +221,4 @@ ${raw(foldBlock({
<p>Feeds of recent documents: <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a>. Source freshness: <a href="/sources">sources</a>.${d.sync && d.sync.last_ok_at ? html` Last synchronised with OpenVibe.Sources ${when(d.sync.last_ok_at)}.` : ''}</p>`;
}
--module.exports = { homePage, homePanes, faqItems, faqJsonLd, change };
++module.exports = { homePage, homePanes, marketBriefPage, faqItems, faqJsonLd, change };
ddiff --git a/server/worker.js b/server/worker.js
iindex 8821907..0c4f3b3 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -12,13 +12,23 @@
* their last read (domain/facts.js, TRADE_SEC_PER_TICK at most), then write AI briefs for the
* companies whose numbers changed (domain/briefs.js, TRADE_BRIEFS_PER_TICK at most, within the
* site's hourly budget)
++ * market brief every TRADE_MARKET_BRIEFS_INTERVAL_MS: write today's market-wide AI brief if none exists yet
++ * (domain/market-briefs.js; one a UTC day at most)
*/
--function createWorker({ config, sync, freshness, indexing, outbox, facts = null, briefs = null, sec = null, log = console }) {
++function createWorker({ config, sync, freshness, indexing, outbox, facts = null, briefs = null, marketBriefs = null, sec = null, log = console }) {
let syncTimer = null;
let freshTimer = null;
let briefsTimer = null;
++ let marketTimer = null;
let briefsRunning = false;
++ /** One market-wide brief a day at most; the daily cap lives in the domain's brief_date key. */
++ async function marketBriefsTick() {
++ if (!marketBriefs || !marketBriefs.enabled) return null;
++ const r = await marketBriefs.tick();
++ return r && r.created ? { created: 1 } : { created: 0 };
++ }
++
async function briefsTick() {
if (briefsRunning) return null;
briefsRunning = true;
@@@ -66,6 +76,7 @@ function createWorker({ config, sync, freshness, indexing, outbox, facts = null,
return {
freshnessTick,
briefsTick,
++ marketBriefsTick,
start() {
if (!config.worker.enabled) return;
if (sync.enabled) {
@@@ -79,8 +90,13 @@ function createWorker({ config, sync, freshness, indexing, outbox, facts = null,
briefsTimer = setInterval(briefsTick, config.briefs.intervalMs);
briefsTimer.unref();
}
++ if (marketBriefs && marketBriefs.enabled) {
++ marketTimer = setInterval(() => marketBriefsTick().catch(() => {}), config.marketBriefs.intervalMs);
++ marketTimer.unref();
++ setTimeout(() => marketBriefsTick().catch(() => {}), 5000).unref();
++ }
},
-- stop() { clearInterval(syncTimer); clearInterval(freshTimer); clearInterval(briefsTimer); },
++ stop() { clearInterval(syncTimer); clearInterval(freshTimer); clearInterval(briefsTimer); clearInterval(marketTimer); },
};
}
ddiff --git a/test/market-brief.test.js b/test/market-brief.test.js
nnew file mode 100644
iindex 0000000..ae85feb
---- /dev/null
++++ b/test/market-brief.test.js
@@@ -0,0 +1,216 @@
++'use strict';
++/**
++ * The market-wide brief (domain/market-briefs.js, the worker tick, the home and GET /api/v1/review-queue):
++ * - built only from what Trade stored: the day's filings by form, the euro reference rates, the companies with new
++ * filings or SEC figures and aggregate watchlist activity (no network beyond the injected AI seam)
++ * - every point cites a Trade page; an uncited point is dropped and listed as a gap
++ * - one a UTC day at most: a second ask makes no second AI call
++ * - unreviewed, the home is noindex and the brief is in the editor queue; approving it indexes the home
++ * - without OpenVibe.AI nothing is made and nothing renders
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const quiet = { log() {}, warn() {}, error() {} };
++const MARKET_WORKFLOW = 'trade.summarize_market_context';
++
++(async () => {
++ const aiCalls = [];
++ let advice = false;
++ const ai = {
++ enabled: true, WORKFLOW: MARKET_WORKFLOW,
++ async run(input) {
++ aiCalls.push(input);
++ const form = input.sources.findIndex((s) => s.source_type === 'trade.filings_by_form');
++ const rate = input.sources.findIndex((s) => s.source_type === 'trade.rate');
++ const company = input.sources.findIndex((s) => s.source_type === 'trade.instrument');
++ const watch = input.sources.findIndex((s) => s.source_type === 'trade.watchlists');
++ return {
++ runId: `run_market_${aiCalls.length}`, model: 'test-model', stub: false,
++ output: {
++ summary: advice
++ ? 'We recommend buying ACME now: the latest filing makes it a sure thing.'
++ : 'Filing activity led the day, the euro reference rates moved, and one company reported a new figure.',
++ observations: [
++ { text: 'The day\'s filings were led by quarterly reports and current reports.', observed_at: null, citations: [form] },
++ { text: 'The euro was worth a little more in dollars than at the previous reference rate.', observed_at: null, citations: [rate] },
++ { text: 'ACME filed a report and Trade read a new figure from it.', observed_at: null, citations: [company] },
++ ...(watch >= 0 ? [{ text: 'People added instruments to their private watchlists.', citations: [watch] }] : []),
++ { text: 'A sentence with nothing behind it.', citations: [] },
++ ],
++ disclaimer: 'Informational only; not investment advice.',
++ citations: [form], gaps: [],
++ },
++ };
++ },
++ };
++
++ const t = await boot({ ai, log: quiet, env: { TRADE_BRIEFS_INTERVAL_MS: '600000' } });
++ try {
++ const acme = await t.instrument({ symbol: 'ACME', name: 'Acme Corp', cik: '1234567' });
++ const usd = await t.ctx.instruments.create({ kind: 'currency', symbol: 'USD', name: 'US Dollar' }, t.editor.subject);
++ await t.observe(usd, { metric: 'rate', value: '1.1650', unit: 'ratio', currency: null, source_ref: 'rate-usd-today', observed_at: t.iso(t.T0 - 3600e3), retrieved_at: t.iso(t.T0 - 1800e3) });
++ await t.observe(usd, { metric: 'rate', value: '1.1600', unit: 'ratio', currency: null, source_ref: 'rate-usd-before', observed_at: t.iso(t.T0 - 86400e3), retrieved_at: t.iso(t.T0 - 82800e3) });
++ await t.ctx.documents.upsert({
++ source_key: 'sec-xbrl-filings', source_item_id: 'itm_market_1', source_revision: 1, title: 'Acme Corp (0001234567) (Filer)',
++ url: 'https://www.sec.gov/Archives/edgar/data/1234567/000123456726000001/0001234567-26-000001-index.htm', published_at: t.T0 - 1800e3,
++ source_updated_at: null, retrieved_at: t.T0, first_seen_at: t.T0, license_note: null, terms_note: null,
++ kind: 'filing', form_type: '10-Q', filer_name: 'Acme Corp', cik: '0001234567', accession: '0001234567-26-000001',
++ }, acme);
++ await t.observe(acme, { metric: 'revenue.q', value: '1000000', unit: 'USD', currency: 'USD', source_key: 'sec-companyfacts', source_ref: '1234567:0001234567-26-000001:Revenue:CY2026Q3', period: '3 months to 2026-09-30', observed_at: t.iso(t.T0 - 86400e3), retrieved_at: t.iso(t.T0) });
++ // An instrument brief so the home has the "Latest AI briefs" section the market brief must sit above.
++ await t.ctx.store.db.prepare(`INSERT INTO trade_ai_briefs (id, instrument_id, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
++ VALUES ('tbr_market_seed', @iid, 'ACME filed a quarterly report.', '[]', '[]', '[]', @as_of, 'seed', 'run_seed', 'test-model', 'report', @created)`)
++ .run({ iid: acme.id, as_of: t.T0, created: t.T0 });
++ const watch = await t.ctx.watchlists.create(t.alice.subject, { name: 'Mine' });
++ await t.ctx.watchlists.add(watch, acme);
++
++ await check('built only from stored rows: the day\'s filings, rates, companies and watchlist count', async () => {
++ const r = await t.ctx.marketBriefs.make();
++ assert.strictEqual(r.created, true, JSON.stringify(r));
++ assert.strictEqual(aiCalls.length, 1);
++ const input = aiCalls[0];
++ assert.deepStrictEqual(input.instrument, { symbol: 'MARKET', name: 'The US market: every company Trade covers' });
++ assert.strictEqual(input.stale_after_hours, 24);
++ assert.ok(input.sources.length > 0 && input.sources.length <= 50);
++ assert.deepStrictEqual(input.sources.map((s) => s.source_type), ['trade.filings_by_form', 'trade.rate', 'trade.instrument', 'trade.watchlists']);
++ assert.match(input.sources[0].snippet, /1 10-Q filings? were published or first seen in the last 24 hours/);
++ assert.match(input.sources[1].snippet, /1 EUR = 1\.1650 USD on .* from 1\.1600/);
++ assert.match(input.sources[2].snippet, /1 new filing \(10-Q\) and 1 new SEC figure/);
++ assert.match(input.sources[3].snippet, /1 instrument was added to people's private watchlists/);
++ for (const s of input.sources) assert.ok(!/^https?:/.test(s.url), `a Trade page, not the source's own URL: ${s.url}`);
++ for (const s of input.sources) assert.ok(!/price/i.test(s.snippet), 'no prices in the market brief');
++ const b = r.brief;
++ assert.strictEqual(b.status, 'unreviewed');
++ assert.strictEqual(b.points.length, 4, 'the uncited point is dropped');
++ assert.ok(b.gaps.some((g) => /nothing behind it/.test(g)), 'and listed as a gap');
++ assert.ok(b.points.every((p) => p.cites.length && p.cites.every((c) => c.url.startsWith('/'))), 'every claim links to a Trade page');
++ assert.ok(b.cites.every((c) => c.url.startsWith('/')));
++ });
++
++ await check('one UTC day at most: a second ask makes no second AI call', async () => {
++ const again = await t.ctx.marketBriefs.make();
++ assert.strictEqual(again.created, false);
++ assert.strictEqual(again.brief.id, (await t.ctx.marketBriefs.latest()).id);
++ assert.strictEqual(aiCalls.length, 1);
++ assert.strictEqual((await t.ctx.worker.marketBriefsTick()).created, 0);
++ assert.strictEqual(aiCalls.length, 1);
++ });
++
++ await check('unreviewed: the home stays indexable and links to the brief page, which is noindex and in the queue', async () => {
++ const home = await t.get('/');
++ assert.strictEqual(home.status, 200);
++ assert.ok(home.text.includes('<meta name="robots" content="index, follow">'), 'the home stays indexable while the brief is unreviewed');
++ assert.ok(home.text.includes("Today's market brief (AI-written, not yet reviewed)"), 'the home links to the unreviewed brief');
++ assert.ok(home.text.includes('href="/market/2026-09-22"'), 'the link goes to the brief\'s own page');
++ assert.ok(!home.text.includes('id="market-brief"'), 'the home does not show the unreviewed AI text');
++ assert.ok(!/Quarterly reports led the day/.test(home.text), 'no unreviewed brief text on the home');
++ const pages = await t.get('/sitemaps/pages.xml');
++ assert.ok(pages.text.includes('<loc>https://openvibe.trade/</loc>'), 'the home keeps its sitemap entry');
++ assert.ok(!pages.text.includes('<loc>https://openvibe.trade/market/2026-09-22</loc>'), 'an unreviewed brief page is not in the sitemap');
++
++ const briefPage = await t.get('/market/2026-09-22');
++ assert.strictEqual(briefPage.status, 200);
++ assert.ok(briefPage.text.includes('<meta name="robots" content="noindex, nofollow">'), 'the brief page is noindex until reviewed');
++ assert.ok(briefPage.text.includes('AI-written, not yet reviewed by a person'));
++ assert.ok(briefPage.text.includes('Filing activity led the day'), 'the page shows the brief');
++ assert.ok(!briefPage.text.includes('AI-written, reviewed by a person'));
++
++ const missing = await t.get('/market/2026-01-01');
++ assert.strictEqual(missing.status, 404);
++
++ const no = await t.get('/api/v1/review-queue', { as: t.alice });
++ assert.strictEqual(no.status, 403);
++ 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.kind === 'market_brief');
++ assert.ok(item, 'the market brief is waiting');
++ assert.strictEqual(item.site, 'trade');
++ assert.strictEqual(item.url, '/market/2026-09-22');
++ assert.strictEqual(item.editUrl, '/market/2026-09-22');
++ assert.ok(item.reasons.some((x) => x.code === 'ai_generated_unreviewed'));
++ assert.match(item.review.path, /^\/api\/v1\/market-briefs\/tmk_[^/]+\/review$/);
++ });
++
++ await check('approving as a person makes the brief page indexable and the home shows it in full', async () => {
++ const b = await t.ctx.marketBriefs.latest();
++ const no = await t.get(`/api/v1/market-briefs/${b.id}/review`, { as: t.alice, json: { decision: 'approved' } });
++ assert.strictEqual(no.status, 403);
++ const r = await t.get(`/api/v1/market-briefs/${b.id}/review`, { as: t.editor, json: { decision: 'approved', note: 'Checked against the day\'s filings' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().brief.reviewed, true);
++
++ const briefPage = await t.get('/market/2026-09-22');
++ assert.ok(briefPage.text.includes('<meta name="robots" content="index, follow">'), 'the reviewed brief page is indexable');
++ assert.ok(briefPage.text.includes('AI-written, reviewed by a person'));
++
++ const home = await t.get('/');
++ assert.ok(home.text.includes('<meta name="robots" content="index, follow">'), 'the home stays indexable');
++ assert.ok(home.text.includes('id="market-brief"'), 'the home shows the reviewed brief in full');
++ assert.ok(home.text.includes('AI-written, reviewed by a person'));
++ assert.ok(!home.text.includes("Today's market brief (AI-written, not yet reviewed)"), 'no teaser once a reviewed brief exists');
++ assert.ok(home.text.indexOf('id="market-brief"') < home.text.indexOf('id="briefs-h"'), 'the market brief is above the instrument briefs');
++
++ const pages = await t.get('/sitemaps/pages.xml');
++ assert.ok(pages.text.includes('<loc>https://openvibe.trade/</loc>'), 'the home is still in the sitemap');
++ assert.ok(pages.text.includes('<loc>https://openvibe.trade/market/2026-09-22</loc>'), 'the reviewed brief page joins the sitemap');
++
++ const q = await t.get('/api/v1/review-queue', { as: t.editor });
++ assert.ok(!q.json().items.some((i) => i.kind === 'market_brief'), 'a reviewed brief leaves the queue');
++ const again = await t.get(`/api/v1/market-briefs/${b.id}/review`, { as: t.editor, json: { decision: 'approved' } });
++ assert.strictEqual(again.status, 409, 'a review is recorded once');
++ });
++
++ await check('the next day: a new brief, and advice is refused without storing one', async () => {
++ t.clock.advance(24 * 3600e3);
++ advice = true;
++ await assert.rejects(() => t.ctx.marketBriefs.make(), (e) => e.status === 422 && e.code === 'market_brief.advice_refused');
++ assert.strictEqual(aiCalls.length, 2);
++ assert.strictEqual((await t.ctx.marketBriefs.latest()).date, '2026-09-22', 'the refused text left the previous day\'s brief alone');
++ advice = false;
++ const next = await t.ctx.marketBriefs.make();
++ assert.strictEqual(next.created, true);
++ assert.strictEqual(next.brief.date, '2026-09-23');
++ assert.strictEqual(aiCalls.length, 3);
++ });
++
++ await check('a newer unreviewed brief never replaces the reviewed one on the home', async () => {
++ assert.strictEqual((await t.ctx.marketBriefs.latestReviewed()).date, '2026-09-22');
++ assert.strictEqual((await t.ctx.marketBriefs.latestUnreviewed()).date, '2026-09-23');
++ const home = await t.get('/');
++ assert.ok(home.text.includes('<meta name="robots" content="index, follow">'));
++ assert.ok(home.text.includes('id="market-brief"'));
++ assert.ok(home.text.includes('<time datetime="2026-09-22">2026-09-22</time>'), 'the reviewed brief, with its date');
++ assert.ok(!home.text.includes("Today's market brief (AI-written, not yet reviewed)"), 'no teaser while a reviewed brief exists');
++ assert.ok(!home.text.includes('<time datetime="2026-09-23">'), 'not the unreviewed one\'s date');
++ const nextPage = await t.get('/market/2026-09-23');
++ assert.strictEqual(nextPage.status, 200);
++ assert.ok(nextPage.text.includes('<meta name="robots" content="noindex, nofollow">'), 'the newer brief\'s own page is noindex');
++ const pages = await t.get('/sitemaps/pages.xml');
++ assert.ok(pages.text.includes('<loc>https://openvibe.trade/market/2026-09-22</loc>'));
++ assert.ok(!pages.text.includes('<loc>https://openvibe.trade/market/2026-09-23</loc>'));
++ });
++
++ const t2 = await boot({ ai: { enabled: false, WORKFLOW: MARKET_WORKFLOW, async run() { throw new Error('OpenVibe.AI is not configured'); } }, log: quiet });
++ try {
++ await t2.ctx.store.db.prepare(`INSERT INTO trade_market_briefs (id, brief_date, summary, points, cites, gaps, as_of, inputs_hash, run_id, model, trigger, created_at)
++ VALUES ('tmk_test_old', '2026-09-22', 'An old AI brief.', '[]', '[]', '[]', 1, 'h', 'run', 'm', 'schedule', 1)`).run();
++ await check('without OpenVibe.AI nothing is made, nothing is shown and nothing fails', async () => {
++ assert.strictEqual(t2.ctx.marketBriefs.enabled, false);
++ assert.deepStrictEqual(await t2.ctx.marketBriefs.make(), { brief: null, created: false });
++ assert.strictEqual(await t2.ctx.marketBriefs.latest(), null);
++ const r = await t2.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(!r.text.includes('id="market-brief"'), 'no market brief section');
++ assert.ok(!r.text.includes("Today's market brief"), 'no link to a brief');
++ assert.ok(r.text.includes('<meta name="robots" content="index, follow">'), 'the stale row changes nothing');
++ assert.strictEqual((await t2.get('/market/2026-09-22')).status, 404, 'an old brief page is not served without OpenVibe.AI');
++ });
++ } finally {
++ await t2.close();
++ }
++ } finally {
++ await t.close();
++ done();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });