openvibers/openvibe.reviews Commits

Reviews: a method box, transaction evidence and sources side by side (#157)

OP OpenVibers committed 13 days ago · 1b2dddabea · 5 files, +408 −37

* Reviews: a method box, transaction evidence and sources side by side

Every entity page now publishes where its reviews come from, how they are collected and kept, how one is removed, the checks that really run, and the fake-review detection rate we do not have. A source review row carries its provenance and, when the source recorded a purchase, a "Verified purchase" badge that says evidence, not truth. The aggregate line shows its sample and how many of that sample bought it. Sources are counted side by side, each with its own figure. The home's latest-review cards and every member review say "Written here" with the author and date.

Outcome 5 (a removal changelog) is left out: no removal changelog table exists.

* Reviews: human provenance words and one plain aggregate line

Review rows now read "Steam · read 20 Sep 2026 · 10 h played · English": no raw item id or revision in the visible line (both stay as data attributes), dates in words, and language names instead of slugs. The aggregate block puts one plain line first ("85.7% recommend · 7 reviews · 5 bought on Steam"); the revision, computed date and method link moved inside the Computation fold. Member, seen-rating and home dates are in words too.

Changes

ddiff --git a/public/css/outcomes.css b/public/css/outcomes.css
nnew file mode 100644
iindex 0000000..8f63538
---- /dev/null
++++ b/public/css/outcomes.css
@@@ -0,0 +1,20 @@
++/* Entity-page outcomes from the market research (2026-10-31): each source keeps its own count beside the others,
++ and every review row carries its provenance. The rows and the fold are openvibe-shared/kit, loaded on entity
++ pages only; these rules use the site's theme tokens, so no colour is introduced here. */
++.rv-source-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }
++.rv-source-card { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 12px 14px; border: 1px solid var(--rv-line); border-radius: 12px; background: var(--rv-panel); overflow-wrap: anywhere; }
++.rv-source-head { margin: 0; font-size: 1rem; line-height: 1.3; }
++.rv-source-figure { margin: 0; }
++.rv-source-figure .rv-big { font-size: 1.45rem; }
++.rv-source-card details { margin-top: 4px; }
++.rv-source-card details p { margin: 6px 0 0; }
++.rv-review-list .ovk-row:target { outline: 2px solid var(--rv-accent); outline-offset: 2px; }
++/* A review row's provenance is a full sentence, not a one-line list subtitle: let it wrap on a phone. */
++.rv-review-list .ovk-row-top { flex-wrap: wrap; }
++.rv-review-list .ovk-row-top strong { white-space: normal; overflow: visible; text-overflow: clip; }
++.rv-review-list .ovk-row-top time { margin-left: 0; }
++.rv-review-list .ovk-row-sub { align-items: flex-start; }
++.rv-review-list .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; text-overflow: clip; }
++/* The method's rows are sentences too; without this the kit's one-line rule ellipsises them. */
++.rv-method .ovk-row-sub { align-items: flex-start; }
++.rv-method .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; text-overflow: clip; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 063bb0b..ec9f27e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -299,6 +299,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console, limi
const signedIn = !!req.actor.subject;
send(req, res, status, views.entityPage(p, { discussion, actor: req.actor, flash: req.query.saved ? 'Saved.' : null, members, elsewhere, seen, records, reviewError, reviewValues }), {
decision: p.decision, title: e.name, cache: discussion.state === 'ok' || signedIn ? null : 'public', path: svc.entityPath(e),
++ styles: ['kit.css'], localStyles: ['css/outcomes.css'],
description: e.description && !svc.LOCAL_KINDS.has(e.kind) ? e.description
: `${e.name}${e.description ? ` (${e.description})` : ''}: ${members.stats.count ? `${members.stats.average} out of 5 from ${members.stats.count} OpenVibe member review${members.stats.count === 1 ? '' : 's'}, ` : ''}review signals${p.sources.length ? ` from ${p.sources.map((s) => s.name || s.key).join(', ')}` : ''}, and every other review site in one place.`,
canonical: p.entity.url, jsonLd: structuredData(p, config, members),
ddiff --git a/server/render/views.js b/server/render/views.js
iindex eff9fd6..0b94c86 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -9,8 +9,11 @@
*/
const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
const { pickBlock, foldBlock } = require('./short');
++// Per-source counts on an entity page use the same method as the page's aggregate.
++const { computeAggregate } = require('../reviews/aggregate');
const { html, raw } = ssr;
const t = (v) => raw(ssr.timeTag(v));
@@@ -18,6 +21,29 @@ const tl = (v) => raw(ssr.timeTag(v, { label: v ? new Date(v).toISOString().repl
const e = encodeURIComponent;
const n = (x) => (x == null ? '' : Number(x).toLocaleString('en-US'));
++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++/** A date people read: "20 Sep 2026". The page never prints a raw timestamp. */
++function dateWords(v) {
++ if (!v) return '';
++ const d = new Date(v);
++ if (Number.isNaN(d.getTime())) return '';
++ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
++}
++
++// Steam's language field is a slug; people read a language name.
++const LANGUAGE_LABEL = {
++ english: 'English', german: 'German', french: 'French', italian: 'Italian', koreana: 'Korean', spanish: 'Spanish',
++ schinese: 'Simplified Chinese', tchinese: 'Traditional Chinese', russian: 'Russian', thai: 'Thai', japanese: 'Japanese',
++ portuguese: 'Portuguese', polish: 'Polish', danish: 'Danish', dutch: 'Dutch', finnish: 'Finnish', norwegian: 'Norwegian',
++ swedish: 'Swedish', hungarian: 'Hungarian', czech: 'Czech', romanian: 'Romanian', turkish: 'Turkish', brazilian: 'Brazilian Portuguese',
++ bulgarian: 'Bulgarian', greek: 'Greek', ukrainian: 'Ukrainian', vietnamese: 'Vietnamese', indonesian: 'Indonesian', latam: 'Latin American Spanish',
++};
++function languageLabel(v) {
++ const s = String(v || '').trim();
++ if (!s) return '';
++ return LANGUAGE_LABEL[s.toLowerCase()] || s.charAt(0).toUpperCase() + s.slice(1);
++}
++
const KIND_LABEL = { product: 'Product', game: 'Game', software: 'Software', service: 'Service', place: 'Place', organization: 'Organization', media: 'Media', other: 'Other',
employer: 'Employer', landlord: 'Landlord', restaurant: 'Restaurant', business: 'Local business', hosting: 'Hosting provider', app: 'App', vehicle: 'Vehicle',
community: 'Community', platform: 'Platform' };
@@@ -64,16 +90,57 @@ function signalValue(s) {
}
}
--function trustFacts(trust) {
++function trustBits(trust = {}) {
const bits = [];
-- if (trust.steam_purchase === true) bits.push('bought on Steam');
-- if (trust.steam_purchase === false) bits.push('not bought on Steam');
-- if (trust.received_for_free === true) bits.push('received for free');
-- if (trust.written_during_early_access === true) bits.push('written during early access');
-- if (trust.verified_purchase === true) bits.push('verified purchase');
-- if (trust.playtime_at_review_min != null) bits.push(`${n(Math.round(trust.playtime_at_review_min / 60))} h played at review`);
-- if (trust.language) bits.push(`language: ${trust.language}`);
-- return bits.join(' · ');
++ if (trust.steam_purchase === true) bits.push({ key: 'purchase', text: 'bought on Steam' });
++ if (trust.steam_purchase === false) bits.push({ key: 'not-purchase', text: 'not bought on Steam' });
++ if (trust.received_for_free === true) bits.push({ key: 'free', text: 'received for free' });
++ if (trust.written_during_early_access === true) bits.push({ key: 'early', text: 'written during early access' });
++ if (trust.verified_purchase === true) bits.push({ key: 'verified', text: 'verified purchase' });
++ if (trust.playtime_at_review_min != null) bits.push({ key: 'playtime', text: `${n(Math.round(trust.playtime_at_review_min / 60))} h played` });
++ if (trust.language) bits.push({ key: 'language', text: languageLabel(trust.language) });
++ return bits;
++}
++function trustFacts(trust) { return trustBits(trust).map((b) => b.text).join(' · '); }
++
++/** The one-word name a source is known by here ("Steam", "NYC Health"), for a line beside a review. */
++function sourceShortName(name, key) {
++ if (name) {
++ const s = String(name);
++ const steam = s.match(/^(.+?)\s+user review signals:/i);
++ if (steam) return steam[1];
++ const colon = s.indexOf(':');
++ if (colon > 0) return s.slice(0, colon).trim();
++ return s.length > 42 ? `${s.slice(0, 39)}…` : s;
++ }
++ // A source with no recorded name still gets a human label, never its raw key on a page.
++ const k = String(key || '');
++ if (/^steam-reviews-/.test(k)) return 'Steam';
++ if (k === 'cfpb-complaints-by-company') return 'CFPB';
++ if (k === 'nyc-hpd-open-violations') return 'NYC HPD';
++ if (k === 'nyc-restaurant-inspections') return 'NYC Health';
++ return k.replace(/-/g, ' ');
++}
++
++/** A purchase on the source's own record, never a check of ours. A free copy is not a purchase. */
++function purchased(s) {
++ const t = (s && s.trust) || {};
++ return (t.verified_purchase === true || t.steam_purchase === true) && t.received_for_free !== true;
++}
++function receivedFree(s) { return !!((s && s.trust) || {}).received_for_free; }
++
++/** The transaction as the source recorded it: evidence beside the review, not a verdict on it. */
++function evidenceBadge(s, source) {
++ if (receivedFree(s)) return kit.badge('Received for free', 'warn');
++ if (purchased(s)) return kit.badge(`Verified purchase · ${sourceShortName(source && source.name, s && s.source_key)}`, 'ok');
++ return '';
++}
++
++/** The facts beside a review that are not already the evidence badge. */
++function reviewFacts(s) {
++ return trustBits(s.trust)
++ .filter((b) => !(b.key === 'purchase' || b.key === 'free' || b.key === 'verified'))
++ .map((b) => b.text);
}
function errorBody({ status, title, message }) {
@@@ -159,7 +226,7 @@ function paragraphs(text) {
function memberReviewItem(r, { entity, editor }) {
return html`<li><article class="rv-mr" id="${r.id}"><header>${stars(r.rating)} <b>${r.title}</b></header>
--<p class="rv-muted">${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${t(r.created_at)}${r.edited ? ' (edited)' : ''}</p>
++<p class="rv-muted">Written here by ${r.author_name}${r.relation ? html` · ${r.relation}` : ''} · ${dateWords(r.created_at)}${r.edited ? ' (edited)' : ''}</p>
<div class="rv-mr-body">${paragraphs(r.body)}</div>
${r.mine ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><button type="submit" class="rv-link-button">Delete my review</button></form>`
: editor ? html`<form method="post" action="${epath(entity)}/reviews/${r.id}/remove" class="rv-inline"><input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> <button type="submit" class="rv-link-button">Remove</button></form>` : ''}
@@@ -204,7 +271,7 @@ function elsewhereSection(links, entity, { seen = {}, actor = null, editor = fal
/** One site's rating as a member saw it, with when, how many members saw it, and a way to take it down. */
function seenLine(r, { entity, actor, editor }) {
const mine = actor && actor.subject && r.seen_by === actor.subject;
-- return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${t(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;
++ return html`<span class="rv-seen"><b>${String(r.value)}</b> <span class="rv-muted">out of ${String(r.best)}${r.count != null ? ` from ${n(r.count)} rating${r.count === 1 ? '' : 's'}` : ''} · seen ${dateWords(r.seen_at)}${r.members > 1 ? ` (${r.members} members)` : ''}</span>${mine || editor ? html` <form method="post" action="/save/${r.id}/remove" class="rv-inline"><input type="hidden" name="entity" value="${entity.slug}">${mine ? '' : html`<input type="text" name="reason" placeholder="Reason (kept in the log)" aria-label="Reason for removing" maxlength="300"> `}<button type="submit" class="rv-link-button">${mine ? 'Remove mine' : 'Remove'}</button></form>` : ''}</span>`;
}
/** /save: a member checks the rating another site's page shows (filled in by the Companion, or typed) and saves it. */
@@@ -336,7 +403,7 @@ function home({ entities, total, editor, recent = [], kinds = {}, stats = null,
<ul class="rv-kinds">${browse.map((k) => html`<li><a href="/k/${k}"><b>${KIND_PLURAL[k]}</b><span class="rv-muted">${kinds[k] ? `${n(kinds[k])} here` : 'Be the first'}</span></a></li>`)}</ul>
<p><a class="rv-button" href="/review">Write a review</a> <a href="/everywhere">Search every review site</a></p></section>
${recent.length ? html`<section class="sc-sec" aria-labelledby="rv-recent-h"><h2 id="rv-recent-h">Latest reviews from members</h2>
--<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span></a></li>`)}</ul></section>` : ''}
++<ul class="rv-recent">${recent.map((r) => html`<li><a href="/e/${r.entity.slug}#${r.id}"><span class="rv-card-head"><b>${r.entity.name}</b> <span class="rv-tag">${KIND_LABEL[r.entity.kind]}</span></span>${stars(r.rating)} <b>${r.title}</b><br><span class="rv-muted">${r.body.length > 160 ? `${r.body.slice(0, 157)}…` : r.body}</span><br><span class="rv-muted">Written here by ${r.author_name} · ${dateWords(r.created_at)}</span></a></li>`)}</ul></section>` : ''}
<section id="entities" class="sc-sec" aria-labelledby="rv-board-h"><h2 id="rv-board-h">Scores from sources</h2>
${entities.length ? html`<p class="sc-lede">Each score comes from named sources. Open one to see every signal behind it, when it was read, and how the number was worked out.</p><ul class="rv-board">${entities.map(boardCard)}</ul>`
: html`<p class="rv-muted">Nothing yet. Entities appear here once a source has stated something about them (or an editor has published a summary).</p>`}
@@@ -393,6 +460,18 @@ function aboutPage() {
<li>With no qualifying signal there is no aggregate. Not zero, not a placeholder: none.</li>
</ul>
<p>Each change to the inputs produces a new aggregate revision; the entity history lists them all.</p>
++<h2 id="retention">Retention and removal</h2>
++<p>No deletion window is set for a signal. When a source updates an item the old signal is marked replaced; when it removes one the signal is withdrawn. Both stay in the entity's history and keep their provenance. An editor can exclude a source or a signal, always with a reason shown on the page. A member can delete their own review, and an editor can remove one with a reason; the reason stays in the editorial log.</p>
++<h2 id="checks">What we check</h2>
++<ul>
++<li>The source, the item, its revision and when it was read are on every signal.</li>
++<li>A rating counts only when its source stated the scale and the count; otherwise it is left out, and the page says why.</li>
++<li>A source's own tally is counted once; the individual reviews it covers are not added on top.</li>
++<li>An editor can exclude a source or a signal only with a reason, shown on the page.</li>
++<li>The source's own purchase record is shown beside a review as evidence. It says the reviewer bought it; it does not say the review is true.</li>
++</ul>
++<p>We do not score how likely a review is to be fake. We do not read review text, IP addresses, devices or writing style, and we have no measured fake-review detection rate: none is published.</p>
++<p>There is no invitation or import feed: a review's provenance says it came from a source item, was written here, or is a rating seen on another site by a member.</p>
<h2 id="summaries">Summaries</h2>
<p>A summary is text written by an editor (or drafted by OpenVibe.AI and approved by an editor) whose pros and cons each cite the signals they rest on. A summary never carries a rating. If a cited signal is withdrawn or replaced, the summary is flagged on the page and a revised version waits for an editor.</p>
<h2 id="entities">Entities, merges and splits</h2>
@@@ -416,13 +495,25 @@ function aggregateSection(p) {
const r = p.aggregate.result;
const c = r.components;
const byId = new Map([...p.signals, ...p.inactive_signals].map((s) => [s.signal_id, s]));
++ // The sample and, where the source recorded one, the purchase evidence inside it. Never a count we did not keep.
++ const inComponent = (x) => x.inputs.map((id) => byId.get(id)).filter(Boolean);
++ const bought = (x) => inComponent(x).filter(purchased).length;
++ const allSteam = (x) => inComponent(x).length > 0 && inComponent(x).every((s) => /^steam-/.test(s.source_key));
++ const purchasePhrase = (x) => {
++ const k = bought(x);
++ if (!k) return '';
++ return allSteam(x) ? `${n(k)} bought on Steam` : `${n(k)} with a purchase on record`;
++ };
++ const ratePurchase = c.rating ? purchasePhrase(c.rating) : '';
++ const recPurchase = c.recommendation ? purchasePhrase(c.recommendation) : '';
++ // One plain line first; the revision, when it was computed and the method live in the Computation fold.
++ const recLine = c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend · ${n(c.recommendation.total)} review${c.recommendation.total === 1 ? '' : 's'}${recPurchase ? html` · ${recPurchase}` : ''}</p>` : '';
++ const rateLine = c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> · ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${ratePurchase ? html` · ${ratePurchase}` : ''}</p>` : '';
return html`<section class="rv-aggregate">${head}
--<div class="rv-agg-box">
--${c.rating ? html`<p class="rv-agg"><strong>${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</strong> from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}${c.rating.on_scale ? html` <span class="rv-muted">(${c.rating.percent}% of the best possible rating)</span>` : ''}</p>` : ''}
--${c.recommendation ? html`<p class="rv-agg"><strong>${c.recommendation.percent}%</strong> recommend: ${n(c.recommendation.positive)} of ${n(c.recommendation.total)}</p>` : ''}
--<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${tl(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${t(r.observed.earliest)} to ${t(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>
--</div>
--<details><summary>Computation</summary><ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul>
++<div class="rv-agg-box">${recLine}${rateLine}</div>
++<details><summary>Computation</summary>
++<p class="rv-muted">Aggregate revision ${String(p.aggregate.revision)}, computed ${dateWords(p.aggregate.computed_at)} from ${String(r.inputs.length)} signal${r.inputs.length === 1 ? '' : 's'} of ${String(r.sources.length)} source${r.sources.length === 1 ? '' : 's'}, observed ${dateWords(r.observed.earliest)} to ${dateWords(r.observed.latest)}. <a href="/about#method">Method ${r.method}</a>.</p>
++<ul>${r.computation.map((line) => html`<li><code>${line}</code></li>`)}</ul>
<p>Inputs: ${r.inputs.map((i, k) => html`${k ? ', ' : ''}<a href="#${i.signal_id}">${i.signal_id}</a>`)}</p>
${r.excluded.length ? html`<p>Left out:</p><ul>${r.excluded.map((x) => html`<li><a href="#${x.signal_id}">${x.signal_id}</a>${byId.get(x.signal_id) ? html` (${byId.get(x.signal_id).source_key})` : ''}: ${REASON_LABEL[x.reason] || x.reason}${x.note ? html` — ${x.note}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">Nothing was left out.</p>`}
</details></section>`;
@@@ -506,17 +597,109 @@ ${comments.length ? html`<ul class="rv-comments">${comments.map((c) => html`<li>
${form}</section>`;
}
++/**
++ * The method box every entity page publishes (the market research's first outcome): where the reviews come from,
++ * how they are collected and kept, how one is removed, the checks we really run, and the detection rate we do not
++ * have. The source names and count are the page's own; the rest is how server/reviews/aggregate.js and the signal
++ * tables work, so the page never claims a number or a policy we do not hold.
++ */
++function methodBox(p) {
++ const sources = p.sources.length
++ ? `${n(p.sources.length)} source${p.sources.length === 1 ? '' : 's'} on this page: ${p.sources.map((s) => sourceShortName(s.name, s.key)).join(', ')}.`
++ : 'No source has stated anything about this entity yet.';
++ const rows = [
++ kit.row({ title: 'Where reviews come from', sub: `${sources} Members' reviews are written here and kept apart. Every review says where it came from: a source item, written here, or a rating seen on another site by a member. We have no invitation or import feed, so no review claims one.` }),
++ kit.row({ title: 'How we collect them', sub: 'OpenVibe.Sources retrieves each source item. We keep the source, the item, its revision and when it was read; the review text stays with the source.' }),
++ kit.row({ title: 'How long we keep them', sub: 'No deletion window is set. A replaced or withdrawn signal stays in this page\'s history.' }),
++ kit.row({ title: 'How we remove one', sub: 'A source that removes an item withdraws its signal; a changed item replaces the old one. An editor can exclude a signal or a source, and the reason is shown beside it.' }),
++ kit.row({ title: 'What we check', sub: 'The source, item and read time on every signal; a rating\'s stated scale and count; one tally per source; an editor\'s exclusion and its reason; the source\'s own purchase record.' }),
++ kit.row({ title: 'What we do not measure', sub: 'No fake-review detection rate: none is measured, so none is published. We do not read review text, IP addresses, devices or writing style.' }),
++ ];
++ return raw(kit.fold({
++ id: 'method', title: 'How this page\'s numbers are made', meta: 'method, retention, checks',
++ body: `<div class="rv-method">${kit.list(rows, { flush: true })}</div><p class="rv-muted"><a href="/about#checks">The full method, retention and checks</a></p>`,
++ }));
++}
++
++/** One source's own reviews, counted with the same method as the page's aggregate. */
++function sourceCard(src, signals) {
++ // The signal view says `recommended: true | false`; computeAggregate reads the stored 1 | 0.
++ const rows = signals.map((s) => ({ ...s, id: s.signal_id, recommended: s.recommended === true ? 1 : s.recommended === false ? 0 : s.recommended }));
++ const result = computeAggregate(rows);
++ const c = result && result.components;
++ const figures = [];
++ if (c && c.rating) figures.push(html`<p class="rv-source-figure"><span class="rv-big">${c.rating.on_scale ? `${c.rating.on_scale.value} / ${c.rating.on_scale.best}` : `${c.rating.percent}%`}</span> <span class="rv-muted">from ${n(c.rating.count)} rating${c.rating.count === 1 ? '' : 's'}</span></p>`);
++ if (c && c.recommendation) figures.push(html`<p class="rv-source-figure"><span class="rv-big">${c.recommendation.percent}%</span> <span class="rv-muted">recommend from ${n(c.recommendation.total)} review${c.recommendation.total === 1 ? '' : 's'}</span></p>`);
++ const readAt = signals.map((s) => s.provenance && s.provenance.retrieved_at).filter(Boolean).sort().pop();
++ const limits = [];
++ if (src.stale) limits.push('the source was stale when last checked');
++ if (src.license_note) limits.push(`licence: ${src.license_note}`);
++ if (src.trust && src.trust.limitation) limits.push(src.trust.limitation.value);
++ if (src.trust && src.trust.verification) limits.push(`verification: ${src.trust.verification.value}`);
++ const site = safeUrl(src.homepage_url);
++ return html`<li class="rv-source-card">
++<p class="rv-source-head"><b>${src.name || sourceShortName(null, src.key)}</b></p>
++${figures.length ? figures : html`<p class="rv-muted">No qualifying signal: the source did not state a scale and a count. ${n(signals.length)} signal${signals.length === 1 ? '' : 's'}.</p>`}
++<p class="rv-muted">Read${readAt ? html` ${dateWords(readAt)}` : ''}${site ? html` · <a href="${site}" rel="nofollow noopener">source site</a>` : ''}</p>
++${src.trust && src.trust.aggregate ? html`<p>${raw(kit.badge('Excluded from the aggregate', 'warn'))} <span class="rv-muted">${src.trust.aggregate.note || 'an editor excluded this source'}</span></p>` : ''}
++${limits.length ? html`<p class="rv-muted">${limits.join(' · ')}</p>` : ''}
++${src.terms_note ? html`<details class="rv-source-terms"><summary>Terms</summary><p class="rv-muted">${src.terms_note}</p></details>` : ''}
++</li>`;
++}
++
++/** Every source's own count side by side, so disagreement stays visible instead of being averaged away. */
++function sourcesSection(p) {
++ if (!p.sources.length) return '';
++ const byKey = new Map();
++ for (const s of p.signals) {
++ if (!byKey.has(s.source_key)) byKey.set(s.source_key, []);
++ byKey.get(s.source_key).push(s);
++ }
++ return html`<section id="sources" aria-labelledby="sources-h"><h2 id="sources-h">Sources side by side</h2>
++<p class="rv-muted">Each source's own reviews and its own count. A source with more reviews does not outweigh another.</p>
++<ul class="rv-source-cards">${p.sources.map((src) => sourceCard(src, byKey.get(src.key) || []))}</ul>
++${p.entity_trust.limitation ? html`<p class="rv-limits">${p.entity_trust.limitation.value}</p>` : ''}
++<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
++</section>`;
++}
++
++/** Every source review as a row: its value, the transaction the source recorded, and its provenance. */
++function sourceReviewRows(p) {
++ const byKey = new Map(p.sources.map((s) => [s.key, s]));
++ return p.signals.map((s) => {
++ const src = byKey.get(s.source_key);
++ const badge = evidenceBadge(s, src);
++ const facts = reviewFacts(s);
++ const reviewed = s.source_published_at ? kit.when(s.source_published_at) : null;
++ const read = dateWords(s.provenance && s.provenance.retrieved_at);
++ const provenance = html`${sourceShortName(src && src.name, s.source_key)}${read ? html` · read ${read}` : ''}${facts.length ? html` · ${facts.join(' · ')}` : ''}`;
++ const link = safeUrl(s.canonical_url) || safeUrl(src && src.homepage_url);
++ return kit.row({
++ attrs: { id: s.signal_id, 'data-item-id': s.source_item_id, 'data-item-revision': String(s.item_revision) },
++ title: signalValue(s),
++ badge,
++ meta: reviewed ? reviewed.text : '',
++ metaIso: reviewed ? reviewed.iso : '',
++ subHtml: String(provenance),
++ trail: link ? `<a href="${ssr.escapeHtml(link)}" rel="nofollow noopener">Open</a>` : '',
++ });
++ });
++}
++
++function sourceReviewsSection(p) {
++ if (!p.signals.length) return '';
++ const rows = sourceReviewRows(p);
++ return raw(kit.section({
++ id: 'source-reviews', title: 'Reviews from sources', count: p.signals.length,
++ note: 'Each row keeps its source, when it was read, and whether the source recorded a purchase. A purchase on the source\'s record is evidence the reviewer bought it, not that the review is true. The words stay with the people who wrote them.',
++ cls: 'rv-review-list', body: kit.list(rows),
++ }));
++}
++
function entityPage(p, { discussion, actor, flash, members = null, elsewhere = [], seen = {}, records = null, reviewError = null, reviewValues = null }) {
const en = p.entity;
// Something of its own to show (members' reviews or official records): the empty source sections stay out.
const own = (members && members.stats.count) || (records && (records.complaints || records.housing || records.inspections.length));
-- const limits = [];
-- for (const s of p.sources) {
-- if (s.stale) limits.push(html`${s.name || s.key}: the source was stale when last checked${s.last_success_at ? html` (last successful fetch ${t(s.last_success_at)})` : ''}.`);
-- if (s.trust.limitation) limits.push(html`${s.name || s.key}: ${s.trust.limitation.value}`);
-- if (s.trust.verification) limits.push(html`${s.name || s.key} verification: ${s.trust.verification.value}`);
-- }
-- if (p.entity_trust.limitation) limits.push(html`${p.entity_trust.limitation.value}`);
const otherNames = p.aliases.filter((a) => a.type === 'name' && a.value !== en.name);
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name }])}
<article class="rv-entity">
@@@ -527,16 +710,11 @@ ${records ? recordsSection(records, en) : ''}
${members ? memberSection(members, { entity: en, actor, editor: p.editor, error: reviewError, values: reviewValues }) : ''}
${p.merged_from.length ? html`<p class="rv-muted">Includes the signals of ${p.merged_from.map((m, i) => html`${i ? ', ' : ''}${m.name}`)} (merged here; see <a href="${epath(en)}/history#merges">history</a>).</p>` : ''}
${p.aggregate || p.signals.length || p.sources.length || !own ? aggregateSection(p) : ''}
++${methodBox(p)}
${p.summary || p.editor || !own ? summarySection(p) : ''}
--${p.signals.length || p.sources.length || p.inactive_signals.length ? html`<section><h2 id="signals">Signals from sources (${String(p.signals.length)})</h2>
--${p.signals.length ? signalTable(p.signals, { caption: 'Signals from sources, with provenance' }) : html`<p class="rv-muted">No source has stated anything about this entity yet.</p>`}
++${sourcesSection(p)}
++${sourceReviewsSection(p)}
${p.inactive_signals.length ? html`<p class="rv-muted">${String(p.inactive_signals.length)} earlier signal(s) were replaced or withdrawn by their sources: <a href="${epath(en)}/history#signals">history</a>.</p>` : ''}
--</section>
--<section><h2 id="limits">Sources and limitations</h2>
--${p.sources.length ? html`<ul>${p.sources.map((s) => html`<li><strong>${s.name || s.key}</strong>${safeUrl(s.homepage_url) ? html` (<a href="${safeUrl(s.homepage_url)}" rel="nofollow noopener">site</a>)` : ''}${s.terms_note ? html`<br><span class="rv-muted">Terms: ${s.terms_note}</span>` : ''}${s.trust.aggregate ? html`<br><span class="rv-tag">excluded from the aggregate</span> ${s.trust.aggregate.note || ''}` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No sources yet.</p>`}
--${limits.length ? html`<ul class="rv-limits">${limits.map((l) => html`<li>${l}</li>`)}</ul>` : ''}
--<p class="rv-muted">Coverage is what the sources above returned, nothing more: a source that samples recent reviews, one language or one shop describes that sample, not everyone's opinion.${p.open_corrections ? ` ${p.open_corrections} correction(s) are waiting for an editor.` : ''}</p>
--</section>` : ''}
${elsewhereSection(elsewhere, en, { seen, actor, editor: p.editor })}
${otherNames.length || p.links.length ? html`<section><h2>Also known as and related</h2>
${otherNames.length ? html`<p>Also known as ${otherNames.map((a, i) => html`${i ? ', ' : ''}${a.value}`)}</p>` : ''}
ddiff --git a/test/nojs.test.js b/test/nojs.test.js
iindex 00c2239..1913df7 100644
---- a/test/nojs.test.js
++++ b/test/nojs.test.js
@@@ -58,8 +58,8 @@ t('AggregateRating JSON-LD is absent without signals, present only while signals
assert.deepStrictEqual(product.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.4, ratingCount: 321, bestRating: 5 });
assert.ok(!ld.some((x) => x['@type'] === 'Review'), 'no Review without a published summary');
const visible = noScripts(page.text);
-- assert.match(visible, /4\.4 \/ 5<\/strong> from 321 ratings/);
-- assert.match(visible, new RegExp(item.id));
++ assert.match(visible, /<strong>4\.4 \/ 5<\/strong> · 321 ratings/);
++ assert.match(visible, new RegExp(`data-item-id="${item.id}"`));
await deliver(h, sourcesEvent('sources.item.removed', h.sources.remove(item.id, 'takedown'), { reason: 'takedown' }));
page = await req(h, 'GET', `/e/${e.slug}`);
@@@ -80,7 +80,7 @@ t('a recommendation-only entity: AggregateRating is the real share and count, on
const page = await req(h, 'GET', `/e/${e.slug}`);
const game = jsonLd(page.text).find((x) => x['@type'] === 'VideoGame');
assert.deepStrictEqual(game.aggregateRating, { '@type': 'AggregateRating', ratingValue: 75, ratingCount: 4, bestRating: 100, worstRating: 0 });
-- assert.match(noScripts(page.text), /75%<\/strong> recommend: 3 of 4/);
++ assert.match(noScripts(page.text), /75%<\/strong> recommend · 4 reviews · 4 bought on Steam/);
assert.ok(!/★/.test(page.text));
} finally { await h.stop(); }
});
@@@ -105,7 +105,7 @@ t('every public page reads without JavaScript; sitemaps, feeds and robots follow
assert.strictEqual(robots(page.text), 'index, follow');
assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=60, stale-while-revalidate=3600');
const text = noScripts(page.text);
-- for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Retrieved', 'steam-reviews-portal-2', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
++ for (const needle of ['Portal 2', '50%</strong> recommend', 'Half of the sample recommends it', 'Steam · read', 'Steam', 'Computation', '/history', '/correct', 'Discussion']) assert.ok(text.includes(needle), needle);
assert.ok(jsonLd(page.text).some((x) => x['@type'] === 'Review'));
sm = await req(h, 'GET', '/sitemaps/entities-1.xml');
assert.ok(sm.text.includes(`http://reviews.test/e/${e.slug}`));
ddiff --git a/test/outcomes.test.js b/test/outcomes.test.js
nnew file mode 100644
iindex 0000000..7ad1f26
---- /dev/null
++++ b/test/outcomes.test.js
@@@ -0,0 +1,172 @@
++'use strict';
++/**
++ * The market research's outcomes for Reviews (2026-10-30), built 2026-10-31: a method box on every entity page, a
++ * verified transaction shown as evidence, a rating with its sample, provenance on every card, the checks we really
++ * run (and no detection rate we cannot measure), and sources side by side with their own counts.
++ *
++ * Outcome 5 (a removal changelog) is not here: the database has no removal changelog table.
++ */
++const assert = require('assert');
++const {
++ boot, req, suite, steamItem, productItem, baseItem, importItem, createEntity, userToken, cookieFor, subject,
++} = require('./helpers');
++
++const t = suite('outcomes');
++const BODY = 'The staff were kind and the work was steady, but the rota changed at the last minute more than once.';
++
++function addSteam(h) {
++ h.sources.addSource({ key: 'steam-reviews-portal-2', name: 'Steam user review signals: Portal 2 (app 620)', homepage_url: 'https://store.steampowered.com/app/620/', category: 'reviews' });
++}
++function addShop(h, key = 'shop-example', name = 'Example shop ratings', url = 'https://shop.example/') {
++ h.sources.addSource({ key, name, homepage_url: url, category: 'reviews' });
++}
++function signal(source, identity, fields) {
++ return baseItem({ source, kind: 'review_signal', identity, fields });
++}
++async function portal(h, aliases = [{ type: 'source', value: 'steam-reviews-portal-2' }]) {
++ return await createEntity(h, { name: 'Portal 2', kind: 'game', aliases });
++}
++
++t('outcome 1: the method box is on every entity page and names that page\'s own sources', async () => {
++ const h = await boot();
++ try {
++ addSteam(h);
++ await importItem(h, steamItem({ votedUp: true }));
++ const e = await portal(h);
++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
++ assert.match(page, /<details class="ovk-fold" id="method"/, 'the method box');
++ assert.match(page, /How this page(?:&#39;|')s numbers are made/);
++ assert.match(page, /Where reviews come from/);
++ assert.match(page, /1 source on this page: Steam/, 'the source is the page\'s own');
++ assert.match(page, /Members(?:&#39;|') reviews are written here and kept apart/);
++ assert.match(page, /How we collect them/);
++ assert.match(page, /How long we keep them/);
++ assert.match(page, /No deletion window is set/);
++ assert.match(page, /How we remove one/);
++ assert.match(page, /no invitation or import feed/, 'we say which origins we do not have');
++ assert.match(page, /href="\/about#checks"/);
++
++ // An entity with no source at all still publishes the method, and says so rather than borrowing a source.
++ const empty = await createEntity(h, { name: 'Nothing Yet', kind: 'other' });
++ const emptyPage = (await req(h, 'GET', `/e/${empty.slug}`)).text;
++ const box = emptyPage.slice(emptyPage.indexOf('id="method"'), emptyPage.indexOf('</details>', emptyPage.indexOf('id="method"')));
++ assert.match(box, /No source has stated anything about this entity yet/);
++ assert.ok(!box.includes('Steam'), 'no source is named on a page that has none');
++
++ const about = (await req(h, 'GET', '/about')).text;
++ assert.match(about, /<h2 id="retention">Retention and removal<\/h2>/);
++ assert.match(about, /No deletion window is set/);
++ assert.match(about, /<h2 id="checks">What we check<\/h2>/);
++ } finally { await h.stop(); }
++});
++
++t('outcome 2: a purchase the source recorded is shown as evidence, never as truth', async () => {
++ const h = await boot();
++ try {
++ addSteam(h);
++ await importItem(h, steamItem({ votedUp: true })); // bought on Steam
++ await importItem(h, signal('steam-reviews-portal-2', 'not-bought', { voted_up: true, steam_purchase: false, received_for_free: false }));
++ await importItem(h, signal('steam-reviews-portal-2', 'free-copy', { voted_up: true, steam_purchase: true, received_for_free: true }));
++ const e = await portal(h);
++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
++ assert.strictEqual((page.match(/Verified purchase · Steam/g) || []).length, 1, 'one verified purchase, not the false or free ones');
++ assert.match(page, /Received for free/, 'a free copy is labelled as such, not as a purchase');
++ assert.match(page, /not bought on Steam/, 'the source\'s no-purchase record stays a fact');
++ assert.match(page, /evidence the reviewer bought it, not that the review is true/);
++ } finally { await h.stop(); }
++});
++
++t('outcome 3: every rating carries its sample, and the purchases inside the sample are counted', async () => {
++ const h = await boot();
++ try {
++ addSteam(h);
++ await importItem(h, steamItem({ votedUp: true }));
++ await importItem(h, steamItem({ votedUp: true }));
++ await importItem(h, signal('steam-reviews-portal-2', 'nay', { voted_up: false, steam_purchase: false, received_for_free: false }));
++ const game = await portal(h);
++ const page = (await req(h, 'GET', `/e/${game.slug}`)).text;
++ assert.match(page, /<strong>66\.7%<\/strong> recommend · 3 reviews · 2 bought on Steam/);
++
++ addShop(h);
++ await importItem(h, productItem({ value: 4.2, best: 5, count: 120, url: 'https://shop.example/p/portal-2' }));
++ const product = await createEntity(h, { name: 'Widget 3000', kind: 'product', aliases: [{ type: 'source', value: 'shop-example' }] });
++ const shopPage = (await req(h, 'GET', `/e/${product.slug}`)).text;
++ assert.match(shopPage, /<strong>4\.2 \/ 5<\/strong> · 120 ratings/);
++
++ // A members' rating carries its own sample too.
++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
++ const employer = await createEntity(h, { name: 'Acme Corp', kind: 'employer' });
++ assert.strictEqual((await req(h, 'POST', `/e/${employer.slug}/reviews`, { cookie: ana, form: { rating: '4', title: 'Steady', body: BODY } })).status, 303);
++ assert.match((await req(h, 'GET', `/e/${employer.slug}`)).text, /from 1 review written here by people with an OpenVibe account/);
++
++ // The front page's score cards keep the count too.
++ assert.match((await req(h, 'GET', '/')).text, /120 ratings/);
++ } finally { await h.stop(); }
++});
++
++t('outcome 4: every card says where the review came from and when', async () => {
++ const h = await boot();
++ try {
++ addSteam(h);
++ await importItem(h, steamItem({ votedUp: true }));
++ const e = await portal(h);
++ const ana = cookieFor(userToken({ subject: subject(), username: 'ana' }));
++ assert.strictEqual((await req(h, 'POST', `/e/${e.slug}/reviews`, { cookie: ana, form: { rating: 4, title: 'Still good', body: BODY, relation: 'Played it' } })).status, 303);
++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
++ assert.match(page, /Steam · read 20 Sep 2026 · 10 h played · English/, 'a source review names its source, when it was read, and its facts');
++ assert.match(page, /data-item-id="itm_[0-9A-Z]+"/, 'the item id is kept as a data attribute');
++ assert.ok(!/from a source item itm_/.test(page), 'the raw item id is not read by people');
++ assert.match(page, /<time datetime="2026-09-01T00:00:00.000Z">/, 'the source date is on the row');
++ assert.match(page, /<article class="rv-mr" id="mrv_[0-9A-Z]{26}">[\s\S]{0,700}Written here by ana · Played it · /, 'a member review keeps its origin, author, relation and date');
++ assert.match((await req(h, 'GET', '/')).text, /Written here by ana · /, 'the home card keeps it too');
++ } finally { await h.stop(); }
++});
++
++t('outcome 6: the checks we use are published, and the detection rate we do not have is not claimed', async () => {
++ const h = await boot();
++ try {
++ addSteam(h);
++ await importItem(h, steamItem({ votedUp: true }));
++ const e = await portal(h);
++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
++ assert.match(page, /<strong>What we check<\/strong>/);
++ assert.match(page, /The source, item and read time on every signal/);
++ assert.match(page, /stated scale and count/);
++ assert.match(page, /one tally per source/);
++ assert.match(page, /the source&#39;s own purchase record/);
++ assert.match(page, /No fake-review detection rate: none is measured, so none is published/);
++ assert.match(page, /We do not read review text, IP addresses, devices or writing style/);
++ assert.ok(!/\d+(?:\.\d+)?% (?:accuracy|precision|recall)/i.test(page), 'no unmeasured detection rate');
++
++ const about = (await req(h, 'GET', '/about')).text;
++ assert.match(about, /We do not score how likely a review is to be fake/);
++ assert.match(about, /no measured fake-review detection rate: none is published/);
++ } finally { await h.stop(); }
++});
++
++t('outcome 7: sources sit side by side, each with its own count', async () => {
++ const h = await boot();
++ try {
++ addSteam(h);
++ addShop(h);
++ await importItem(h, steamItem({ votedUp: true }));
++ await importItem(h, steamItem({ votedUp: false }));
++ await importItem(h, productItem({ value: 4.2, best: 5, count: 120, url: 'https://shop.example/p/portal-2' }));
++ const e = await portal(h, [{ type: 'source', value: 'steam-reviews-portal-2' }, { type: 'source', value: 'shop-example' }]);
++ const page = (await req(h, 'GET', `/e/${e.slug}`)).text;
++ assert.match(page, /<h2 id="sources-h">Sources side by side<\/h2>/);
++ const start = page.indexOf('<ul class="rv-source-cards">');
++ const end = page.indexOf('</ul>', start);
++ const cards = page.slice(start, end);
++ assert.strictEqual((cards.match(/<li class="rv-source-card">/g) || []).length, 2, 'one card per source');
++ const shopAt = cards.indexOf('Example shop ratings');
++ const steamAt = cards.indexOf('Steam user review signals');
++ assert.ok(shopAt >= 0 && steamAt >= 0, 'both sources are named');
++ const shopCard = cards.slice(shopAt, steamAt);
++ const steamCard = cards.slice(steamAt);
++ assert.match(shopCard, /4\.2 \/ 5<\/span> <span class="rv-muted">from 120 ratings/);
++ assert.match(steamCard, /50%<\/span> <span class="rv-muted">recommend from 2 reviews/);
++ assert.match(page, /<strong>4\.2 \/ 5<\/strong> · 120 ratings/, 'the aggregate keeps the rating sample');
++ assert.match(page, /<strong>50%<\/strong> recommend · 2 reviews · 2 bought on Steam/, 'the aggregate keeps the recommendation sample');
++ } finally { await h.stop(); }
++});