openvibers/openvibe.reviews Commits
A correction yields a public summary revision
Changes
@@@ -91,7 +91,16 @@ revision (citations moved to replacements, unsupported points dropped) waits for**Corrections.** Signed-in people send corrections (entity, alias, signal, summary, aggregate, optionalevidence URL) from the entity page or `reviews.correction.submit`; editors accept or reject them on the--desk. Correction text is not published.++desk. What a request says and who sent it are never published. A correction of a published summary is++a new immutable summary revision carrying a public correction note (who: the editor who wrote it; when:++its time), approved by that editor and published at once: accepting a request about an entity whose++summary is published needs that note (the text is carried forward unless the editor changes it, from++the desk or the edit page), and an editor can correct on their own with `correction_note` on a new++revision. The entity page shows the corrected text, a "Corrected" notice and the summary's revision++history with every correction note; earlier revisions stay readable at `/e/:slug/summary/:n`. Rejecting++creates nothing. AI-drafted text a person corrected becomes AI-assisted (hybrid), never "written by a++person". JSON-LD (`dateModified`), the feeds (a new entry that starts with the note) and the Search++document follow the corrected revision.**Pages (SSR, useful without JavaScript).** `/`, `/about`, `/search`, `/e/:slug` (aggregate with inputsand computation, summary with citations, signal table with provenance and timestamps, sources and@@@ -135,11 +144,11 @@ writes need an editor who is a person (staff or `REVIEWS_EDITORS`). Errors are R| Capability | Routes ||---|---|| `reviews.entity.resolve` | `POST /resolve`, `GET /entities`, `GET /entities/:ref`, `/history`, `/aggregate`, `/summary/revisions/:n`, `GET /items`, `GET /items/:id`, `POST /items/:id/resolution` |--| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` |++| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` `{ status, note?, correction_note?, summary? }` (accepting with a correction also needs `reviews.summary.publish`) || `reviews.entity.merge` / `reviews.entity.split` | `POST /entities/:ref/merge` `{ into, note }`, `POST /entities/:ref/split` `{ note }` || `reviews.signal.import` | `POST /signals/import` `{ source_item_id }`, `POST /sources/sync` || `reviews.summary.propose` | `POST /entities/:ref/summary/proposals` (OpenVibe.AI) |--| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions`, `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |++| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions` (`correction_note`, `correction_id`: a correction, published at once), `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` || `reviews.correction.submit` | `POST /entities/:ref/corrections` |The ids and the service manifest are released in openvibe-contracts v0.20.0 (from the proposals in@@@ -152,7 +161,9 @@ decides them with the contracts grant rule. `reviews.entity.manage` andProduced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,`reviews.entity.split`, `reviews.signal.added` (with `replaces`), `reviews.signal.removed` (status,--reason, `replaced_by`), `reviews.summary.published|updated|unpublished`, and the Search index events++reason, `replaced_by`), `reviews.summary.published|updated|unpublished` (a correction is++`reviews.summary.updated` with `correction: { note, corrects }`; a dedicated `reviews.summary.corrected`++would first need registering in OpenVibe.Contracts), and the Search index events`reviews.index_document.upserted|deleted`. Consumed: `sources.item.created|updated|removed`(subscription created with `scripts/subscribe.js`).@@@ -191,6 +202,7 @@ by editors; signals arrive only from Sources.- **provenance on every signal**, enforced by the schema, immutable by trigger; review text never stored (`signals.test.js`)- **no-JS**: every public page and every editor workflow works with plain HTML and form posts; Community comments are shown, never copied (`nojs.test.js`)- a withdrawn cited signal flags the summary and prepares a pending revision that only a person can publish (`summaries.test.js`)++- **a correction yields a public revision**: accepting a request (or an editor correcting on their own) creates a new immutable revision with the correction note, the page shows the corrected text and the revision history with the note without JavaScript, the old revision stays readable, Search/JSON-LD/feeds/events follow it, the request's text and sender never reach a public surface, a rejection creates nothing, and only an editor who is a person decides (`corrections.test.js`)- deterministic resolution; a name alone never resolves; permissions and capabilities enforced (`resolution.test.js`)- every event validates as `events.event-envelope@1` and every index document as `search.index-document@1`; proposals validate against the contracts schemas; nothing seeded (`signals.test.js`, `proposals.test.js`)@@@ -25,7 +25,8 @@* POST /items/:id/resolution reviews.entity.resolve { entity, add_alias? } | { ignore: true, note? } editor* POST /signals/import reviews.signal.import { source_item_id } fetch the item from Sources and apply it* POST /sources/sync reviews.signal.import pull the next pages of review items from Sources-- * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish? }++ * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish?, correction_note?, correction_id? }++ * with correction_note: a correction of the published summary (published at once)* POST /entities/:ref/summary/proposals reviews.summary.propose { overview?, pros?, cons?, workflow: { id, run_id }, stub_provider?, note? }* GET /entities/:ref/summary/revisions/:n reviews.entity.resolve* POST /entities/:ref/summary/revisions/:n/review reviews.summary.publish { decision: approved|rejected, note?, publish? }@@@ -33,11 +34,13 @@* POST /entities/:ref/summary/unpublish reviews.summary.publish* POST /entities/:ref/corrections reviews.correction.submit { target_type, target_id?, body, evidence_url? } a person* GET /corrections reviews.entity.manage open corrections (editors)-- * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note? }++ * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note?, correction_note?, summary? }++ * accepting with a published summary corrects it (+ reviews.summary.publish)*/const express = require('express');const contracts = require('openvibe-contracts');const { actorMiddleware, guard, run } = require('./common');++const { checkCapability } = require('../auth/capabilities');const { http } = contracts;@@@ -142,7 +145,15 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');return { corrections: svc.openCorrections() };}));-- router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => ({ correction: svc.resolveCorrection(req.params.id, body(req), req.actor) })));++ router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => {++ const b = body(req);++ // Accepting with a correction publishes a summary revision: a service needs that grant too.++ if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {++ const c = checkCapability(req.actor.claims, 'reviews.summary.publish');++ if (!c.allowed) throw new svc.ReviewsError(403, c.code, c.reason);++ }++ return svc.resolveCorrection(req.params.id, b, req.actor);++ }));return router;}@@@ -5,7 +5,8 @@** /robots.txt crawl rules + sitemap location + explicit automated-consumer policy* /sitemap.xml sitemap index → /sitemaps/pages.xml, /sitemaps/entities-<n>.xml-- * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1)++ * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1); one entry per published++ * revision, so a correction is a new entry that starts with its note* /llms.txt orientation for language models*/const express = require('express');@@@ -53,7 +54,8 @@ function createMachine({ svc, config }) {id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,url: `${svc.entityUrl(entity)}#summary`,title: `${entity.name}: summary`,-- summary: [rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)].join('\n').slice(0, 1000),++ summary: [rev.meta && rev.meta.correction ? `Correction: ${rev.meta.correction.note}` : null, rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)]++ .filter((x) => x != null).join('\n').slice(0, 1000),published: summary.revision_published_at,updated: summary.revision_published_at,tags: [entity.kind],@@@ -9,7 +9,7 @@* GET /e/:slug entity page: aggregate with inputs, summary, signals with provenance, discussion* GET /e/:slug.json the same data as JSON* GET /e/:slug/history aggregate revisions, summary revisions, merges and splits, every signal, editorial log-- * GET /e/:slug/summary/:n one summary revision (editors: approve / reject)++ * GET /e/:slug/summary/:n one summary revision (editors: approve / reject); corrections carry their note* GET|POST /e/:slug/correct a signed-in person sends a correction to the editors* POST /e/:slug/discuss comment through OpenVibe.Community* Editors: GET /editor, POST /editor/sync, GET|POST /editor/entities/new, GET|POST /e/:slug/edit,@@@ -47,12 +47,15 @@ function structuredData(p, config) {const pub = p.summary && p.summary.published;if (pub && pub.points.some((x) => x.supported)) {const text = [pub.overview || '', ...pub.points.filter((x) => x.kind !== 'overview').map((x) => `${x.kind === 'pro' ? 'Pro' : 'Con'}: ${x.text}`)].join('\n').trim();-- out.push(seo.structuredData.review({++ const review = seo.structuredData.review({url: `${en.url}#summary`, itemReviewed: { type, name: en.name, url: en.url },author: { type: 'Organization', name: 'OpenVibe.Reviews editors', url: `${origin}/about` },-- datePublished: p.summary.revision_published_at, body: text.slice(0, 5000),++ datePublished: p.summary.published_at || p.summary.revision_published_at, body: text.slice(0, 5000),publisher: { name: 'OpenVibe', url: 'https://openvibe.network' },-- }));++ });++ // A later revision (an update or a correction) is the same review, modified.++ if (review && p.summary.published_at && p.summary.revision_published_at !== p.summary.published_at) review.dateModified = p.summary.revision_published_at;++ out.push(review);}return out.filter(Boolean);}@@@ -75,6 +78,7 @@ function summaryFromForm(b) {pros: points('pro'), cons: points('con'),expected_revision: b.expected_revision != null && b.expected_revision !== '' ? Number(b.expected_revision) : undefined,message: b.message || null, publish: b.publish === '1',++ correction_note: b.correction_note || null, correction_id: b.correction_id || null,};}@@@ -266,15 +270,21 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {}}));-- function editPage(req, res, e, { status = 200, error = null, flash = null } = {}) {-- send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });++ /** An open correction request about `e` (the summary form then answers it). */++ function openCorrectionFor(e, id) {++ const c = id ? svc.correction(String(id)) : null;++ return c && c.status === 'open' && svc.canonicalOf(c.entity_id) === e.id ? c : null;++ }++++ function editPage(req, res, e, { status = 200, error = null, flash = null, correcting = null } = {}) {++ send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, correcting, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });}router.get('/e/:slug/edit', (req, res) => {if (!editorOnly(req, res)) return;const e = locate(req, res, '/edit');if (!e) return;-- editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null });++ editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: openCorrectionFor(e, req.query.correction) });});router.post('/e/:slug/edit', form, wrap(async (req, res) => {if (!editorOnly(req, res)) return;@@@ -311,7 +321,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);} catch (err) {if (!err.status || err.status >= 500) throw err;-- editPage(req, res, e, { status: err.status, error: err.message });++ editPage(req, res, e, { status: err.status, error: err.message, correcting: openCorrectionFor(e, (req.body || {}).correction_id) });}}));@@@ -364,7 +374,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {router.post('/editor/corrections/:id', form, wrap(async (req, res) => {if (!editorOnly(req, res)) return;const b = req.body || {};-- svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null }, req.actor);++ svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);res.redirect(303, '/editor#corrections');}));@@@ -99,7 +99,7 @@ function aboutPage() {<h2 id="entities">Entities, merges and splits</h2><p>Items are matched to entities by deterministic rules: a source bound to one entity, the item's URL, a GTIN, SKU or MPN. A name alone is never enough: an editor confirms it. A merge never rewrites anything, so a split restores the earlier attribution exactly; both are recorded with who, when and why.</p><h2 id="corrections">Corrections and discussion</h2>--<p>Anyone signed in can send a correction to the editors from an entity page. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p>++<p>Anyone signed in can send a correction to the editors from an entity page. What you send, and who you are, stays with the editors. When an editor corrects a published summary, the correction is a new revision published with a note saying what was corrected; the earlier revisions stay readable in the summary's revision history. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p></section>`;}@@@ -133,6 +133,20 @@ function citeLinks(cites) {return html`${cites.map((c, i) => html`${i ? ' ' : ''}<a href="#${c.signal_id}" class="${c.ok ? 'rv-cite' : 'rv-cite rv-cite-gone'}" title="${c.ok ? 'cited signal' : `cited signal is ${c.status}`}">[${c.signal_id.slice(-6)}${c.ok ? '' : ` ${c.status}`}]</a>`)}`;}++/** The summary's public revision history: every revision readers may see, with each correction note. */++function summaryHistory(en, s) {++ const list = s.history || [];++ if (!list.length) return '';++ const corrections = list.filter((r) => r.correction).length;++ return html`<h3 id="summary-history">Revision history</h3>++<p class="rv-muted">${corrections ? `${corrections} correction${corrections === 1 ? '' : 's'}. ` : ''}Earlier revisions stay readable; a correction is a new revision, never an edit of an old one.</p>++<ul class="rv-revisions">${list.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${tl(r.created_at)}${r.disclosure ? html` · ${r.disclosure}` : ''}${r.correction ? html`<br><strong>Correction:</strong> ${r.correction.note}` : ''}</li>`)}</ul>`;++}++++function correctionNotice(r, at) {++ return html`<p class="rv-notice rv-correction" role="status"><strong>Corrected</strong> ${t(at)}${r.correction.corrects ? ` (revision ${r.correction.corrects} corrected by revision ${r.number})` : ''}: ${r.correction.note}</p>`;++}++function summarySection(p) {const s = p.summary;const head = html`<h2 id="summary">Summary</h2>`;@@@ -146,13 +160,15 @@ function summarySection(p) {const ov = r.points.find((x) => x.kind === 'overview');return html`<section class="rv-summary">${head}${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by OpenVibe.Reviews editors. Revision ${String(r.number)}, published ${t(s.revision_published_at)}.</p>`}++${r.correction ? correctionNotice(r, s.revision_published_at) : ''}${s.flagged ? notice('warn', `Under review: ${s.flag_reason || 'a cited signal changed'} (${s.flagged_at ? s.flagged_at.slice(0, 10) : ''}). Points whose citations are marked below may no longer be supported.`) : ''}${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 2 }))}${ov ? html`<p class="rv-muted">Cites ${citeLinks(ov.citations)}</p>` : ''}</div>` : ''}<div class="rv-proscons"><div><h3>Pros</h3>${pros.length ? html`<ul>${pros.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div><div><h3>Cons</h3>${cons.length ? html`<ul>${cons.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div></div>--<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p></section>`;++<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p>++${summaryHistory(p.entity, s)}</section>`;}function signalRow(s) {@@@ -233,7 +249,7 @@ function historyPage(h) {<h2 id="aggregates">Aggregate revisions</h2>${h.aggregates.length ? html`<div class="rv-scroll"><table class="rv-history"><thead><tr><th>Revision</th><th>Computed</th><th>Why</th><th>Result</th></tr></thead><tbody>${h.aggregates.map((a) => html`<tr><td>${String(a.revision)}</td><td>${tl(a.computed_at)}</td><td>${a.trigger.replace(/_/g, ' ')}</td><td>${aggregateLine(a)}${a.result ? html` <span class="rv-muted">(${String(a.result.inputs.length)} inputs)</span>` : ''}</td></tr>`)}</tbody></table></div>` : html`<p class="rv-muted">No aggregate has ever been computed: no qualifying signal yet.</p>`}<h2 id="summary">Summary revisions</h2>--${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.message ? html` <span class="rv-muted">${r.message}</span>` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}++${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.correction ? html`<br><strong>Correction:</strong> ${r.correction.note}` : (r.message ? html` <span class="rv-muted">${r.message}</span>` : '')}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}<h2 id="merges">Merges and splits</h2>${h.merges.length ? html`<ul>${h.merges.map((m) => html`<li><a href="${epath(m.from)}">${m.from.name}</a> merged into <a href="${epath(m.to)}">${m.to.name}</a> ${tl(m.merged_at)}${m.note ? html` — ${m.note}` : ''}${m.split_at ? html`; split again ${tl(m.split_at)}${m.split_note ? html` — ${m.split_note}` : ''}` : html` <span class="rv-tag">active</span>`}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}<h2 id="signals">Every signal, including replaced and withdrawn ones</h2>@@@ -247,6 +263,7 @@ function summaryRevisionPage(en, r, { editor }) {return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'History', url: `${epath(en)}/history` }, { name: `Summary revision ${r.number}` }])}<section><h1>Summary revision ${String(r.number)} <span class="rv-tag">${r.status}</span></h1>${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by an editor ${t(r.created_at)}.</p>`}++${r.correction ? correctionNotice(r, r.created_at) : ''}${r.system ? notice('warn', `Prepared automatically after: ${r.system.reason}. ${r.system.dropped_points && r.system.dropped_points.length ? `${r.system.dropped_points.length} point(s) were dropped because every signal they cited is gone.` : ''}`) : ''}${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 1 }))}</div>` : ''}<ul>${r.points.filter((p) => p.kind !== 'overview').map((p) => html`<li><strong>${p.kind === 'pro' ? 'Pro' : 'Con'}:</strong> ${p.text} ${citeLinks(p.citations)}</li>`)}</ul>@@@ -261,7 +278,7 @@ function correctionPage(en, { values = {}, error = null, done = false } = {}) {<section><h1>Suggest a correction for ${en.name}</h1>${done ? notice('ok', 'Thank you. Your correction is in the editors\' queue.') : ''}${error ? notice('error', error) : ''}--<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. Corrections are read by editors; they are not published.</p>++<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. What you write, and who you are, stays with the editors. If they correct the summary, they publish a correction note saying what changed, next to the earlier revision.</p><form method="post" action="${epath(en)}/correct" class="rv-form"><label>About <select name="target_type">${['entity', 'signal', 'summary', 'aggregate', 'alias'].map((x) => html`<option value="${x}"${values.target_type === x ? raw(' selected') : ''}>${x}</option>`)}</select></label><label>Which one (optional: a signal id such as sig_…) <input type="text" name="target_id" maxlength="100" value="${values.target_id || ''}"></label>@@@ -284,7 +301,11 @@ ${pending.length ? html`<ul>${pending.map((x) => html`<li><a href="${epath(x.ent${flagged.length ? html`<ul>${flagged.map((s) => html`<li><a href="${epath(s.entity)}">${s.entity.name}</a>: ${s.flag_reason}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}<h2 id="corrections">Open corrections</h2>${corrections.length ? html`<ul>${corrections.map((c) => html`<li><a href="${epath(c.entity)}">${c.entity.name}</a> · ${c.target_type}${c.target_id ? ` ${c.target_id}` : ''} · ${t(c.created_at)}<blockquote>${c.body}</blockquote>${safeUrl(c.evidence_url) ? html`<a href="${safeUrl(c.evidence_url)}" rel="nofollow noopener">evidence</a>` : ''}--<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-inline"><input type="text" name="note" placeholder="Note" maxlength="500"> <button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}++<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-form">++${c.summary_published ? html`<label>Correction note, published with a new summary revision (required to accept) <input type="text" name="correction_note" maxlength="2000"></label>++<p class="rv-muted">Accepting publishes the current summary text again as a correction revision with this note. To change the text as well, <a href="${epath(c.entity)}/edit?correction=${e(c.id)}#summary-form">correct the summary</a>.</p>` : ''}++<label>Note for editors (not published) <input type="text" name="note" maxlength="500"></label>++<button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}</section>`;}@@@ -308,7 +329,7 @@ function newEntityPage({ values = {}, error = null } = {}) {<button type="submit">Create</button></form></section>`;}--function editEntityPage({ page: p, error = null, flash = null, entities = [] }) {++function editEntityPage({ page: p, error = null, flash = null, correcting = null, entities = [] }) {const en = p.entity;const allSignals = p.signals;const sel = (name, chosen = []) => html`<select name="${name}" multiple size="4">${allSignals.map((s) => html`<option value="${s.signal_id}"${chosen.includes(s.signal_id) ? raw(' selected') : ''}>${s.signal_id.slice(-8)} · ${s.source_key} · ${signalValue(s)}</option>`)}</select>`;@@@ -336,12 +357,14 @@ function editEntityPage({ page: p, error = null, flash = null, entities = [] })<h2>Summary</h2><p class="rv-muted">Every pro and con cites the signals it rests on. A summary carries no rating. Saving creates a new revision; "Save and publish" publishes it.</p>--<form method="post" action="${epath(en)}/summary" class="rv-form">++<form method="post" action="${epath(en)}/summary" class="rv-form" id="summary-form">++${correcting ? html`<div class="rv-notice rv-correction"><p>Correcting the summary in answer to a reader's correction request (${correcting.target_type}${correcting.target_id ? ` ${correcting.target_id}` : ''}):</p><blockquote>${correcting.body}</blockquote><p class="rv-muted">Publishing accepts the request. The request stays with the editors; the correction note below is what readers see.</p></div><input type="hidden" name="correction_id" value="${correcting.id}">` : ''}<label>Overview (Markdown) <textarea name="overview" rows="6" maxlength="6000">${cur ? cur.overview : ''}</textarea></label><label>The overview cites ${sel('overview_signals', ov ? ov.citations.filter((c) => c.ok).map((c) => c.signal_id) : [])}</label><fieldset><legend>Pros</legend>${rows('pro', pros)}</fieldset><fieldset><legend>Cons</legend>${rows('con', cons)}</fieldset><label>Revision note <input type="text" name="message" maxlength="500"></label>++${cur ? html`<label>Correction note (public; fill it in only to correct the published summary: the revision is published at once and the note stays in its history) <input type="text" name="correction_note" maxlength="2000"${correcting ? raw(' required minlength="10"') : ''}></label>` : ''}<input type="hidden" name="expected_revision" value="${String(p.summary ? p.summary.head_revision : 0)}"><button type="submit" name="publish" value="0">Save</button> <button type="submit" name="publish" value="1">Save and publish</button></form>${p.summary && p.summary.pending && p.summary.pending.length ? html`<p>Waiting for review: ${p.summary.pending.map((r, i) => html`${i ? ', ' : ''}<a href="${epath(en)}/summary/${String(r.number)}">revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span>`)}</p>` : ''}@@@ -7,8 +7,8 @@** Every write runs in one SQLite transaction together with the events it causes (transactional* outbox): reviews.entity.merged|split, reviews.signal.added|removed,-- * reviews.summary.published|updated|unpublished, and the Search index events-- * reviews.index_document.upserted|deleted.++ * reviews.summary.published|updated|unpublished (a correction is an `updated` carrying++ * `correction`), and the Search index events reviews.index_document.upserted|deleted.** Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).*/@@@ -34,8 +34,11 @@ const SUMMARY_WORKFLOW = 'reviews.summarize_entity';const MAX_POINTS = 12;const MAX_POINT = 600;const MAX_OVERVIEW = 6000;++const MIN_CORRECTION_NOTE = 10;++const MAX_CORRECTION_NOTE = 2000;// A summary is text with citations. Nothing in it can carry a score.--const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note']);++const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note', 'correction_note', 'correction_id']);++const BODY_KEYS = ['overview', 'overview_signals', 'pros', 'cons'];const RATING_KEY_RE = /(rating|stars?|score|grade|verdict_value)/i;// Numeric ratings in AI text ("4.5/5", "8 out of 10", "★★★★"): AI output never states a rating.const RATING_TEXT_RE = /(\b\d+(?:[.,]\d+)?\s*(?:\/|out of)\s*(?:5|10|100)\b)|[★☆⭐]|\b\d+(?:[.,]\d+)?\s*stars?\b/i;@@@ -332,6 +335,90 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowfor (const p of summaryPoints(rev)) for (const id of p.signals) q.insertCitation.run(summary.id, rev.number, p.key, id);}++ function expectedOf(input, head) {++ if (input.expected_revision != null && input.expected_revision !== '') return Number(input.expected_revision);++ if (input.expectedRevision != null && input.expectedRevision !== '') return Number(input.expectedRevision);++ return head;++ }++++ function createRevision(args) {++ try {++ return revisions.create(args).revision;++ } catch (err) {++ if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });++ throw err;++ }++ }++++ // ── Corrections of a published summary ───────────────────++ const wantsCorrection = (input) => [input.correction_note, input.correction_id].some((v) => v != null && String(v).trim() !== '');++++ /** The correction note is public text: what was wrong and what changed. */++ function checkCorrectionNote(v) {++ const s = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();++ if (s.length < MIN_CORRECTION_NOTE) fail(422, 'correction.note_required', `A correction says what was corrected, in a note readers will see (at least ${MIN_CORRECTION_NOTE} characters)`);++ if (s.length > MAX_CORRECTION_NOTE) fail(422, 'correction.invalid', `A correction note is at most ${MAX_CORRECTION_NOTE} characters`);++ return s;++ }++++ /**++ * Who answers for a correction revision: the correcting editor joins the published revision's++ * authors. AI-drafted text a person corrected is AI-assisted (hybrid), never "written by a person".++ */++ function correctionAuthorship(base, who) {++ const authors = [...new Set([...((base && base.authors) || []), who])];++ if (base && (base.mode === 'ai' || base.mode === 'hybrid') && base.workflow) {++ return authorship.record({ mode: 'hybrid', authors, workflow: base.workflow, stubProvider: !!base.stubProvider });++ }++ return authorship.record({ mode: 'human', authors });++ }++++ /** The published text as summary input (a correction that changes no words, e.g. after a signal fix). */++ function carryForward(rev) {++ const f = rev.fields || {};++ return { overview: rev.content || '', overview_signals: f.overview_signals || [], pros: f.pros || [], cons: f.cons || [] };++ }++++ /**++ * Corrects a published summary: a new immutable revision carrying the public correction note++ * (who: its author, the editor; when: its time), approved by that editor and published at once.++ * Earlier revisions stay in the public history. Without new text the published text is carried++ * forward. An open correction request it answers is accepted in the same transaction; what the++ * request said and who sent it stay with the editors.++ */++ function correctSummary(e, input, { note, requestId = null, resolutionNote = null }, actor, who) {++ const text = checkCorrectionNote(note);++ const summary = q.summary.get(e.id);++ if (!summary || summary.state !== 'published' || !summary.published_revision) fail(409, 'summary.not_published', 'Only a published summary is corrected; save and publish a revision instead');++ let request = null;++ if (requestId != null && String(requestId).trim() !== '') {++ request = q.correction.get(String(requestId).trim());++ if (!request) fail(404, 'correction.not_found', 'No such correction');++ if (canonicalOf(request.entity_id) !== e.id) fail(409, 'correction.other_entity', 'That correction is about another entity');++ if (request.status !== 'open') fail(409, 'correction.closed', `Already ${request.status}`);++ }++ const pub = revisions.get(summary.id, summary.published_revision);++ const body = checkSummaryInput(e.id, BODY_KEYS.some((k) => input[k] !== undefined) ? input : carryForward(pub));++ const revision = createRevision({++ entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,++ meta: {++ authorship: correctionAuthorship(pub.meta && pub.meta.authorship, who),++ correction: { note: text, request: request ? request.id : null, corrects: pub.number },++ },++ author: who, message: input.message ? String(input.message).slice(0, 500) : `Correction: ${text.slice(0, 200)}`, allowUnchanged: true,++ });++ writeCitations(summary, revision);++ reviews.record({ entityId: summary.id, revision: revision.number, reviewer: who, decision: 'approved', note: 'correction' });++ if (request) {++ q.resolveCorrection.run('accepted', who, resolutionNote, now(), request.id);++ audit(actor, 'correction.accepted', e.id, request.id, { note: resolutionNote, summary_revision: revision.number });++ }++ audit(actor, 'summary.corrected', e.id, summary.id, { revision: revision.number, corrects: pub.number, correction_id: request ? request.id : null });++ svc.publishSummary(e.id, { revision: revision.number }, actor);++ const fresh = q.summary.get(e.id);++ return { summary: fresh, revision: summaryRevisionView(fresh, revisions.get(summary.id, revision.number), e.id), published: true, correction: request ? q.correction.get(request.id) : null };++ }++function revisionStatus(summary, rev) {const review = reviews.latest(summary.id, rev.number);const rec = rev.meta && rev.meta.authorship;@@@ -829,10 +916,35 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowdisclosure: rec ? authorship.disclosure(rec, review) : null,review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,system: rev.meta && rev.meta.system ? rev.meta.system : null,++ correction: correctionView(rev),author: rev.author, message: rev.message, created_at: rev.createdAt,};}++ /** The public side of a correction: the note, which revision it corrects, and whether a reader asked for it. */++ function correctionView(rev) {++ const c = rev && rev.meta && rev.meta.correction;++ return c ? { note: c.note, corrects: c.corrects, requested: !!c.request } : null;++ }++++ /** The summary's history as readers may see it, newest first (entity page). */++ function publicHistory(summary) {++ if (!summary) return [];++ return revisions.list(summary.id, { limit: 200 }).filter((r) => publicRevision(summary, r)).map((r) => {++ const d = r.meta && r.meta.authorship ? authorship.disclosure(r.meta.authorship, reviews.latest(summary.id, r.number)) : null;++ return { number: r.number, status: revisionStatus(summary, r), created_at: r.createdAt, disclosure: d ? d.short : null, correction: correctionView(r) };++ });++ }++++ // Audit detail readers never see: which correction request it was, what it said, how editors resolved it.++ function auditDetail(r, editor) {++ const d = parse(r.detail, {});++ if (editor) return d;++ if (/^correction\./.test(r.action)) return {};++ const { correction_id: _hidden, ...rest } = d;++ return rest;++ }++// ── Public API ───────────────────────────────────────────const svc = {access, ReviewsError, entityPath, entityUrl, canonicalOf, closure,@@@ -889,6 +1001,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowpublished_at: toIso(summary.published_at), revision_published_at: toIso(summary.revision_published_at),published: pub ? summaryRevisionView(summary, pub, e.id) : null,pending: editor ? pendingRevisions(summary).map((p) => summaryRevisionView(summary, p.rev, e.id)) : pendingRevisions(summary).length,++ history: publicHistory(summary),} : null,open_corrections: q.openCorrectionsOf.get(e.id).n,decision: decide(e),@@@ -912,8 +1025,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now})),signals: allSignalsIn(closure(e.id)).map(signalView),audit: q.auditOf.all(e.id, e.id).map((r) => ({-- id: r.id, at: toIso(r.at), action: r.action, target: r.target,-- actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: parse(r.detail, {}),++ id: r.id, at: toIso(r.at), action: r.action, target: editor || !/^correction\./.test(r.action) ? r.target : null,++ actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: auditDetail(r, editor),})),};},@@@ -1245,27 +1358,24 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowtrustMap,// Summaries (reviews.summary.publish | reviews.summary.propose) ------------------------ /** An editor writes a summary revision; publish: true publishes it at once. */++ /**++ * An editor writes a summary revision; publish: true publishes it at once. With a++ * correction_note (and optionally the correction_id of a reader's request it answers) the++ * revision corrects the published summary: it carries the note and is published at once.++ */writeSummary(ref, input = {}, actor) {const who = requireEditorPerson(actor, 'Writing a summary');return tx(() => {const e = entityOrFail(ref);if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);++ if (wantsCorrection(input)) return correctSummary(e, input, { note: input.correction_note, requestId: input.correction_id }, actor, who);const body = checkSummaryInput(e.id, input);const summary = ensureSummary(e.id);-- const head = revisions.headNumber(summary.id);-- const expected = input.expected_revision != null ? Number(input.expected_revision) : input.expectedRevision != null ? Number(input.expectedRevision) : head;-- let revision;-- try {-- ({ revision } = revisions.create({-- entityId: summary.id, expectedRevision: expected, content: body.content, fields: body.fields,-- meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,-- message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,-- }));-- } catch (err) {-- if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });-- throw err;-- }++ const revision = createRevision({++ entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,++ meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,++ message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,++ });writeCitations(summary, revision);audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });let published = null;@@@ -1353,7 +1463,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowproduct: 'reviews', type: 'summary', action, id: summary.id, revision: n,actor: hooks.subjectRef(actorId(actor)), decision, now: now(),document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: false, visibility: 'public', canonical_url: entityUrl(fresh), publication_state: 'published', indexability: hooks.searchIndexability(decision) },-- extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null },++ extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },}));}return q.summary.get(e.id);@@@ -1431,18 +1541,40 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.nowreturn q.correction.get(id);});},-- openCorrections() { return q.openCorrections.all().map((c) => ({ ...c, entity: q.entity.get(c.entity_id) })); },++ openCorrections() {++ return q.openCorrections.all().map((c) => {++ const entity = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);++ const s = entity && entity.state === 'active' ? q.summary.get(entity.id) : null;++ return { ...c, entity, summary_published: !!(s && s.state === 'published') };++ });++ },correction(id) { return q.correction.get(String(id)) || null; },-- resolveCorrection(id, { status, note = null } = {}, actor) {-- requireEditorPerson(actor, 'Resolving a correction');++ /**++ * An editor accepts or rejects a correction request. `note` stays with the editors. Accepting++ * a request about an entity whose summary is published corrects that summary: a new revision++ * with the public `correction_note` (and the new text in `summary`, or the published text++ * carried forward). Rejecting creates nothing.++ */++ resolveCorrection(id, { status, note = null, correction_note: correctionNote = null, summary: body = null } = {}, actor) {++ const who = requireEditorPerson(actor, 'Resolving a correction');if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');++ if (body != null && (typeof body !== 'object' || Array.isArray(body))) fail(422, 'summary.invalid', 'summary is { overview, overview_signals, pros, cons }');return tx(() => {const c = q.correction.get(String(id));if (!c) fail(404, 'correction.not_found', 'No such correction');if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);-- q.resolveCorrection.run(status, actorId(actor), note ? String(note).slice(0, 2000) : null, now(), c.id);-- audit(actor, `correction.${status}`, c.entity_id, c.id, { note });-- return q.correction.get(c.id);++ const internal = note ? String(note).slice(0, 2000) : null;++ if (status === 'accepted') {++ const e = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);++ const s = e && e.state === 'active' ? q.summary.get(e.id) : null;++ if (s && s.state === 'published') {++ const out = correctSummary(e, { ...(body || {}) }, { note: correctionNote, requestId: c.id, resolutionNote: internal }, actor, who);++ return { correction: out.correction, revision: out.revision };++ }++ }++ q.resolveCorrection.run(status, who, internal, now(), c.id);++ audit(actor, `correction.${status}`, c.entity_id, c.id, { note: internal });++ return { correction: q.correction.get(c.id), revision: null };});},nnew file mode 100644@@@ -0,0 +1,221 @@++'use strict';++/**++ * Corrections: accepting a reader's correction of a published summary (or an editor correcting it++ * on their own) creates a new immutable summary revision that carries the public correction note,++ * is published at once, and shows on the entity page without JavaScript next to the earlier++ * revisions, which stay readable. The request's text, the internal note and who sent it never++ * reach a public surface. Rejecting creates nothing; only editors decide.++ */++const assert = require('assert');++const {++ boot, req, suite, steamItem, importItem, createEntity, outbox, cookieFor, editorToken, readerToken, serviceToken,++ EDITOR, READER,++} = require('./helpers');++++const t = suite('corrections');++const noScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');++const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++const revCount = (h, summaryId) => h.db.prepare('SELECT COUNT(*) AS n FROM review_summary_revisions WHERE entity_id = ?').get(summaryId).n;++++const OLD = 'Every sampled Steam reviewer recommends it without reservation.';++const NEW = 'Three of the four sampled Steam reviewers recommend it; one does not.';++const NOTE = 'The overview said every sampled reviewer recommends it; one of the four does not.';++const REQUEST = 'The summary claims unanimous praise but a negative review is listed right below it.';++const INTERNAL = 'internal: checked the signal table, reader is right';++++async function setup(h) {++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });++ for (const v of [true, true, true, false]) await importItem(h, steamItem({ votedUp: v }));++ const sigs = h.db.prepare("SELECT id, recommended FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);++ const pos = sigs.filter((s) => s.recommended === 1).map((s) => s.id);++ const neg = sigs.filter((s) => s.recommended === 0).map((s) => s.id);++ const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {++ overview: OLD, overview_signals: pos, pros: [{ text: 'Recommended by the sample', signals: pos }], publish: true,++ } });++ assert.strictEqual(w.status, 201, w.text);++ const summaryId = w.json.summary.id;++ return { e, pos, neg, summaryId };++}++++async function submit(h, e, body = REQUEST) {++ const r = await req(h, 'POST', `/e/${e.slug}/correct`, { cookie: cookieFor(readerToken()), form: { target_type: 'summary', body, evidence_url: 'https://store.steampowered.com/app/620/' } });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ return h.db.prepare('SELECT * FROM review_corrections WHERE body = ?').get(body);++}++++t('accepting a correction publishes a new revision with its note; the page shows it and the history without JavaScript', async () => {++ const h = await boot();++ try {++ const { e, pos, neg, summaryId } = await setup(h);++ const c = await submit(h, e);++ assert.strictEqual(c.submitted_by, READER);++++ // Only an editor who is a person decides, and nothing is created by a refused attempt.++ const patch = (token, body, headers) => req(h, 'PATCH', `/api/v1/corrections/${c.id}`, { token, body, headers });++ const accept = { status: 'accepted', correction_note: NOTE };++ assert.strictEqual((await patch(readerToken(), accept)).status, 403);++ assert.strictEqual((await req(h, 'POST', `/editor/corrections/${c.id}`, { cookie: cookieFor(readerToken()), form: { status: 'accepted', correction_note: NOTE } })).status, 403);++ assert.strictEqual((await req(h, 'POST', `/editor/corrections/${c.id}`, { form: { status: 'accepted', correction_note: NOTE } })).status, 401);++ assert.strictEqual((await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage', 'reviews.summary.publish'] }), accept)).status, 403, 'a service on its own');++ assert.strictEqual((await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage', 'reviews.summary.publish'] }), accept, { 'X-OV-Subject': READER })).status, 403, 'a service acting for a reader');++ const noPublish = await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage'] }), accept, { 'X-OV-Subject': EDITOR });++ assert.strictEqual(noPublish.status, 403, 'publishing a correction revision needs reviews.summary.publish');++ assert.strictEqual(noPublish.json.code, 'capability.denied');++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: readerToken(), body: { overview: NEW, overview_signals: pos, correction_note: NOTE } })).status, 403);++ // A correction of a published summary says what changed.++ const bare = await patch(editorToken(), { status: 'accepted' });++ assert.strictEqual(bare.status, 422);++ assert.strictEqual(bare.json.code, 'correction.note_required');++ assert.strictEqual(revCount(h, summaryId), 1);++ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c.id).status, 'open');++++ const ok = await patch(editorToken(), {++ status: 'accepted', note: INTERNAL, correction_note: NOTE,++ summary: { overview: NEW, overview_signals: [...pos, ...neg], pros: [{ text: 'Recommended by most of the sample', signals: pos }], cons: [{ text: 'Not by everyone', signals: neg }] },++ });++ assert.strictEqual(ok.status, 200, ok.text);++ assert.strictEqual(ok.json.correction.status, 'accepted');++ assert.strictEqual(ok.json.correction.resolved_by, EDITOR);++ assert.strictEqual(ok.json.revision.number, 2);++ assert.strictEqual(ok.json.revision.status, 'published');++ assert.deepStrictEqual(ok.json.revision.correction, { note: NOTE, corrects: 1, requested: true });++ assert.strictEqual(ok.json.revision.author, EDITOR, 'who: the editor who corrected it');++++ // Immutable revisions: the old text is still revision 1; the correction is revision 2.++ const rows = h.db.prepare('SELECT number, content, meta, author FROM review_summary_revisions WHERE entity_id = ? ORDER BY number').all(summaryId);++ assert.deepStrictEqual(rows.map((r) => [r.number, r.content]), [[1, OLD], [2, NEW]]);++ assert.strictEqual(JSON.parse(rows[1].meta).correction.note, NOTE);++ assert.throws(() => h.db.prepare('UPDATE review_summary_revisions SET content = ? WHERE entity_id = ? AND number = 2').run('rewritten', summaryId));++++ // The public page, without JavaScript: the corrected text, the note, and the history.++ const page = await req(h, 'GET', `/e/${e.slug}`);++ assert.strictEqual(page.status, 200);++ const text = noScripts(page.text);++ assert.ok(text.includes(NEW), 'current text reflects the correction');++ assert.ok(!text.includes(OLD), 'the corrected text is not the current text');++ assert.match(text, /<strong>Corrected<\/strong>/);++ assert.match(text, /Revision history/);++ assert.ok(text.includes(`Correction:</strong> ${NOTE}`));++ assert.ok(text.includes(`href="/e/${e.slug}/summary/2"`) && text.includes(`href="/e/${e.slug}/summary/1"`), 'both revisions in the history');++ const r1 = await req(h, 'GET', `/e/${e.slug}/summary/1`);++ assert.strictEqual(r1.status, 200);++ assert.ok(noScripts(r1.text).includes(OLD), 'the old revision stays readable');++ assert.match(r1.text, /superseded/);++ const r2 = noScripts((await req(h, 'GET', `/e/${e.slug}/summary/2`)).text);++ assert.ok(r2.includes(NOTE) && r2.includes(NEW));++ const hist = noScripts((await req(h, 'GET', `/e/${e.slug}/history`)).text);++ assert.ok(hist.includes(NOTE));++++ // Structured data, feeds, events and Search follow the corrected revision.++ const review = jsonLd(page.text).find((x) => x['@type'] === 'Review');++ assert.ok(review.reviewBody.includes(NEW) && !review.reviewBody.includes(OLD));++ assert.ok(review.dateModified, 'the Review says it was modified');++ const feed = JSON.parse((await req(h, 'GET', '/feed.json')).text);++ assert.ok(feed.items[0].summary.startsWith(`Correction: ${NOTE}\n${NEW}`), JSON.stringify(feed.items[0]));++ const events = outbox(h);++ const upd = events.filter((x) => x.event_type === 'reviews.summary.updated');++ assert.strictEqual(upd.length, 1);++ assert.deepStrictEqual(upd[0].payload.correction, { note: NOTE, corrects: 1 });++ assert.strictEqual(upd[0].subject.revision, 2);++ const doc = events.filter((x) => x.event_type === 'reviews.index_document.upserted').pop().payload;++ assert.ok(doc.body.includes(NEW) && !doc.body.includes(OLD), 'Search gets the corrected text');++++ // Nothing private leaks: not the request, not who sent it, not the editors' note.++ const surfaces = [++ page.text, r1.text, hist, (await req(h, 'GET', `/e/${e.slug}.json`)).text,++ (await req(h, 'GET', `/api/v1/entities/${e.slug}`)).text, (await req(h, 'GET', `/api/v1/entities/${e.slug}/history`)).text,++ (await req(h, 'GET', `/api/v1/entities/${e.slug}/summary/revisions/2`)).text,++ (await req(h, 'GET', '/feed.json')).text, (await req(h, 'GET', '/feed.atom')).text, JSON.stringify(events),++ ];++ for (const s of surfaces) {++ for (const secret of [REQUEST, INTERNAL, READER, c.id]) assert.ok(!s.includes(secret), `public surface carries ${secret}`);++ }++ // Editors still see the whole record.++ const edHist = await req(h, 'GET', `/api/v1/entities/${e.slug}/history`, { token: editorToken() });++ assert.ok(edHist.text.includes(c.id) && edHist.text.includes(INTERNAL));++++ // Closed is closed.++ assert.strictEqual((await patch(editorToken(), accept)).status, 409);++ } finally { await h.stop(); }++});++++t('a rejected correction creates no revision; editors correct with plain forms, on their own or from the desk', async () => {++ const h = await boot();++ try {++ const { e, pos, neg, summaryId } = await setup(h);++ const ed = cookieFor(editorToken());++ const c1 = await submit(h, e, 'I think this game deserves a higher score overall, honestly.');++ const desk = noScripts((await req(h, 'GET', '/editor', { cookie: ed })).text);++ assert.match(desk, /name="correction_note"/, 'the desk asks for the public note when a summary is published');++ const rej = await req(h, 'POST', `/editor/corrections/${c1.id}`, { cookie: ed, form: { status: 'rejected', note: 'opinion, not an error', correction_note: 'ignored when rejecting' } });++ assert.strictEqual(rej.status, 303);++ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c1.id).status, 'rejected');++ assert.strictEqual(revCount(h, summaryId), 1, 'rejecting creates no revision');++ assert.ok(!outbox(h).some((x) => x.event_type === 'reviews.summary.updated'));++++ // From the desk: accept with a note, the published text carried forward as a correction revision.++ const c2 = await submit(h, e, 'The signal table lists a review that its source has since edited.');++ const short = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'fixed' } });++ assert.strictEqual(short.status, 422, 'a correction note is a sentence readers can use');++ assert.strictEqual(revCount(h, summaryId), 1);++ const acc = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'Checked the cited signals against their sources; the text stands.' } });++ assert.strictEqual(acc.status, 303);++ const r2 = h.db.prepare('SELECT content, meta FROM review_summary_revisions WHERE entity_id = ? AND number = 2').get(summaryId);++ assert.strictEqual(r2.content, OLD, 'carried forward');++ assert.strictEqual(JSON.parse(r2.meta).correction.note, 'Checked the cited signals against their sources; the text stands.');++++ // From the edit page, answering a request: the form names it and accepts it on publish.++ const c3 = await submit(h, e, 'Only three of the four sampled reviewers recommend it, not all of them.');++ const form = await req(h, 'GET', `/e/${e.slug}/edit?correction=${c3.id}`, { cookie: ed });++ assert.ok(form.text.includes(`name="correction_id" value="${c3.id}"`));++ assert.ok(noScripts(form.text).includes('Only three of the four sampled reviewers'), 'editors see the request');++ const fixed = await req(h, 'POST', `/e/${e.slug}/summary`, { cookie: ed, form: {++ overview: NEW, overview_signals: [...pos, ...neg], pro_text_0: 'Recommended by most of the sample', pro_signals_0: pos,++ con_text_0: 'Not by everyone', con_signals_0: neg, correction_id: c3.id, correction_note: NOTE, expected_revision: '2', publish: '0',++ } });++ assert.strictEqual(fixed.status, 303, fixed.text.slice(0, 400));++ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c3.id).status, 'accepted');++ const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);++ assert.strictEqual(s.published_revision, 3, 'a correction is published at once');++++ // An editor corrects without any request.++ const own = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {++ overview: `${NEW} The sample is recent reviews only.`, overview_signals: pos, correction_note: 'Added that the sample covers recent reviews only.',++ } });++ assert.strictEqual(own.status, 201, own.text);++ assert.strictEqual(own.json.revision.number, 4);++ assert.deepStrictEqual(own.json.revision.correction, { note: 'Added that the sample covers recent reviews only.', corrects: 3, requested: false });++ const page = noScripts((await req(h, 'GET', `/e/${e.slug}`)).text);++ for (const n of [NOTE, 'Added that the sample covers recent reviews only.', 'Checked the cited signals against their sources; the text stands.']) assert.ok(page.includes(n), n);++ assert.match(page, /3 corrections\./);++ assert.ok(!page.includes('opinion, not an error'));++++ // Nothing published → nothing to correct.++ const other = await createEntity(h, { name: 'Unsummarised', kind: 'game' });++ const none = await req(h, 'POST', `/api/v1/entities/${other.slug}/summary/revisions`, { token: editorToken(), body: { overview: 'x', overview_signals: pos, correction_note: 'This cannot be a correction.' } });++ assert.strictEqual(none.status, 409);++ assert.strictEqual(none.json.code, 'summary.not_published');++ } finally { await h.stop(); }++});++++t('a correction of approved AI text is AI-assisted, never "written by a person"', async () => {++ const h = await boot();++ try {++ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });++ for (const v of [true, true, false]) await importItem(h, steamItem({ votedUp: v }));++ const sigs = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all().map((s) => s.id);++ const p = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] }), body: {++ overview: 'All sampled reviewers recommend it.', overview_signals: sigs, workflow: { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ', version: 1 },++ } });++ assert.strictEqual(p.status, 201, p.text);++ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/1/review`, { token: editorToken(), body: { decision: 'approved' } })).status, 200);++ const c = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {++ overview: 'Two of the three sampled reviewers recommend it.', overview_signals: sigs, correction_note: 'The AI draft said all reviewers recommend it; one does not.',++ } });++ assert.strictEqual(c.status, 201, c.text);++ assert.strictEqual(c.json.revision.authorship.mode, 'hybrid');++ const page = noScripts((await req(h, 'GET', `/e/${e.slug}`)).text);++ assert.match(page, /Written by a person with AI assistance/);++ assert.ok(page.includes('The AI draft said all reviewers recommend it; one does not.'));++ } finally { await h.stop(); }++});
@@ -91,7 +91,16 @@ revision (citations moved to replacements, unsupported points dropped) waits for
@@ -91,7 +91,16 @@ revision (citations moved to replacements, unsupported points dropped) waits for
**Corrections.** Signed-in people send corrections (entity, alias, signal, summary, aggregate, optional
**Corrections.** Signed-in people send corrections (entity, alias, signal, summary, aggregate, optional
evidence URL) from the entity page or `reviews.correction.submit`; editors accept or reject them on the
evidence URL) from the entity page or `reviews.correction.submit`; editors accept or reject them on the
-desk. Correction text is not published.
+desk. What a request says and who sent it are never published. A correction of a published summary is
+a new immutable summary revision carrying a public correction note (who: the editor who wrote it; when:
+its time), approved by that editor and published at once: accepting a request about an entity whose
+summary is published needs that note (the text is carried forward unless the editor changes it, from
+the desk or the edit page), and an editor can correct on their own with `correction_note` on a new
+revision. The entity page shows the corrected text, a "Corrected" notice and the summary's revision
+history with every correction note; earlier revisions stay readable at `/e/:slug/summary/:n`. Rejecting
+creates nothing. AI-drafted text a person corrected becomes AI-assisted (hybrid), never "written by a
+person". JSON-LD (`dateModified`), the feeds (a new entry that starts with the note) and the Search
+document follow the corrected revision.
**Pages (SSR, useful without JavaScript).** `/`, `/about`, `/search`, `/e/:slug` (aggregate with inputs
**Pages (SSR, useful without JavaScript).** `/`, `/about`, `/search`, `/e/:slug` (aggregate with inputs
and computation, summary with citations, signal table with provenance and timestamps, sources and
and computation, summary with citations, signal table with provenance and timestamps, sources and
@@ -135,11 +144,11 @@ writes need an editor who is a person (staff or `REVIEWS_EDITORS`). Errors are R
@@ -135,11 +144,11 @@ writes need an editor who is a person (staff or `REVIEWS_EDITORS`). Errors are R
| Capability | Routes |
| Capability | Routes |
|---|---|
|---|---|
| `reviews.entity.resolve` | `POST /resolve`, `GET /entities`, `GET /entities/:ref`, `/history`, `/aggregate`, `/summary/revisions/:n`, `GET /items`, `GET /items/:id`, `POST /items/:id/resolution` |
| `reviews.entity.resolve` | `POST /resolve`, `GET /entities`, `GET /entities/:ref`, `/history`, `/aggregate`, `/summary/revisions/:n`, `GET /items`, `GET /items/:id`, `POST /items/:id/resolution` |
-| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` |
+| `reviews.entity.manage` | `POST|PATCH|DELETE /entities[/:ref]`, aliases, links, `POST /trust`, `GET /corrections`, `PATCH /corrections/:id` `{ status, note?, correction_note?, summary? }` (accepting with a correction also needs `reviews.summary.publish`) |
| `reviews.entity.merge` / `reviews.entity.split` | `POST /entities/:ref/merge` `{ into, note }`, `POST /entities/:ref/split` `{ note }` |
| `reviews.entity.merge` / `reviews.entity.split` | `POST /entities/:ref/merge` `{ into, note }`, `POST /entities/:ref/split` `{ note }` |
| `reviews.signal.import` | `POST /signals/import` `{ source_item_id }`, `POST /sources/sync` |
| `reviews.signal.import` | `POST /signals/import` `{ source_item_id }`, `POST /sources/sync` |
| `reviews.summary.propose` | `POST /entities/:ref/summary/proposals` (OpenVibe.AI) |
| `reviews.summary.propose` | `POST /entities/:ref/summary/proposals` (OpenVibe.AI) |
-| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions`, `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |
+| `reviews.summary.publish` | `POST /entities/:ref/summary/revisions` (`correction_note`, `correction_id`: a correction, published at once), `…/revisions/:n/review`, `…/summary/publish`, `…/summary/unpublish` |
| `reviews.correction.submit` | `POST /entities/:ref/corrections` |
| `reviews.correction.submit` | `POST /entities/:ref/corrections` |
The ids and the service manifest are released in openvibe-contracts v0.20.0 (from the proposals in
The ids and the service manifest are released in openvibe-contracts v0.20.0 (from the proposals in
@@ -152,7 +161,9 @@ decides them with the contracts grant rule. `reviews.entity.manage` and
@@ -152,7 +161,9 @@ decides them with the contracts grant rule. `reviews.entity.manage` and
Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
Produced (transactional outbox → OpenVibe.Events when `EVENTS_URL` is set): `reviews.entity.merged`,
`reviews.entity.split`, `reviews.signal.added` (with `replaces`), `reviews.signal.removed` (status,
`reviews.entity.split`, `reviews.signal.added` (with `replaces`), `reviews.signal.removed` (status,
-reason, `replaced_by`), `reviews.summary.published|updated|unpublished`, and the Search index events
+reason, `replaced_by`), `reviews.summary.published|updated|unpublished` (a correction is
+`reviews.summary.updated` with `correction: { note, corrects }`; a dedicated `reviews.summary.corrected`
+would first need registering in OpenVibe.Contracts), and the Search index events
`reviews.index_document.upserted|deleted`. Consumed: `sources.item.created|updated|removed`
`reviews.index_document.upserted|deleted`. Consumed: `sources.item.created|updated|removed`
(subscription created with `scripts/subscribe.js`).
(subscription created with `scripts/subscribe.js`).
@@ -191,6 +202,7 @@ by editors; signals arrive only from Sources.
@@ -191,6 +202,7 @@ by editors; signals arrive only from Sources.
- **provenance on every signal**, enforced by the schema, immutable by trigger; review text never stored (`signals.test.js`)
- **provenance on every signal**, enforced by the schema, immutable by trigger; review text never stored (`signals.test.js`)
- **no-JS**: every public page and every editor workflow works with plain HTML and form posts; Community comments are shown, never copied (`nojs.test.js`)
- **no-JS**: every public page and every editor workflow works with plain HTML and form posts; Community comments are shown, never copied (`nojs.test.js`)
- a withdrawn cited signal flags the summary and prepares a pending revision that only a person can publish (`summaries.test.js`)
- a withdrawn cited signal flags the summary and prepares a pending revision that only a person can publish (`summaries.test.js`)
+- **a correction yields a public revision**: accepting a request (or an editor correcting on their own) creates a new immutable revision with the correction note, the page shows the corrected text and the revision history with the note without JavaScript, the old revision stays readable, Search/JSON-LD/feeds/events follow it, the request's text and sender never reach a public surface, a rejection creates nothing, and only an editor who is a person decides (`corrections.test.js`)
- deterministic resolution; a name alone never resolves; permissions and capabilities enforced (`resolution.test.js`)
- deterministic resolution; a name alone never resolves; permissions and capabilities enforced (`resolution.test.js`)
- every event validates as `events.event-envelope@1` and every index document as `search.index-document@1`; proposals validate against the contracts schemas; nothing seeded (`signals.test.js`, `proposals.test.js`)
- every event validates as `events.event-envelope@1` and every index document as `search.index-document@1`; proposals validate against the contracts schemas; nothing seeded (`signals.test.js`, `proposals.test.js`)
@@ -25,7 +25,8 @@
@@ -25,7 +25,8 @@
* POST /items/:id/resolution reviews.entity.resolve { entity, add_alias? } | { ignore: true, note? } editor
* POST /items/:id/resolution reviews.entity.resolve { entity, add_alias? } | { ignore: true, note? } editor
* POST /signals/import reviews.signal.import { source_item_id } fetch the item from Sources and apply it
* POST /signals/import reviews.signal.import { source_item_id } fetch the item from Sources and apply it
* POST /sources/sync reviews.signal.import pull the next pages of review items from Sources
* POST /sources/sync reviews.signal.import pull the next pages of review items from Sources
- * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish? }
+ * POST /entities/:ref/summary/revisions reviews.summary.publish { overview?, overview_signals?, pros?, cons?, expected_revision?, message?, publish?, correction_note?, correction_id? }
+ * with correction_note: a correction of the published summary (published at once)
* POST /entities/:ref/summary/proposals reviews.summary.propose { overview?, pros?, cons?, workflow: { id, run_id }, stub_provider?, note? }
* POST /entities/:ref/summary/proposals reviews.summary.propose { overview?, pros?, cons?, workflow: { id, run_id }, stub_provider?, note? }
* GET /entities/:ref/summary/revisions/:n reviews.entity.resolve
* GET /entities/:ref/summary/revisions/:n reviews.entity.resolve
* POST /entities/:ref/summary/revisions/:n/review reviews.summary.publish { decision: approved|rejected, note?, publish? }
* POST /entities/:ref/summary/revisions/:n/review reviews.summary.publish { decision: approved|rejected, note?, publish? }
@@ -33,11 +34,13 @@
@@ -33,11 +34,13 @@
* POST /entities/:ref/summary/unpublish reviews.summary.publish
* POST /entities/:ref/summary/unpublish reviews.summary.publish
* POST /entities/:ref/corrections reviews.correction.submit { target_type, target_id?, body, evidence_url? } a person
* POST /entities/:ref/corrections reviews.correction.submit { target_type, target_id?, body, evidence_url? } a person
* GET /corrections reviews.entity.manage open corrections (editors)
* GET /corrections reviews.entity.manage open corrections (editors)
- * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note? }
+ * PATCH /corrections/:id reviews.entity.manage { status: accepted|rejected, note?, correction_note?, summary? }
+ * accepting with a published summary corrects it (+ reviews.summary.publish)
*/
*/
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { actorMiddleware, guard, run } = require('./common');
const { actorMiddleware, guard, run } = require('./common');
+const { checkCapability } = require('../auth/capabilities');
const { http } = contracts;
const { http } = contracts;
@@ -142,7 +145,15 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
@@ -142,7 +145,15 @@ function createApi({ svc, viewers, platform, sync, config, log = console }) {
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
if (!svc.access.isEditor(req.actor)) throw new svc.ReviewsError(403, 'reviews.editor_required', 'Corrections are read by Reviews editors');
return { corrections: svc.openCorrections() };
return { corrections: svc.openCorrections() };
}));
}));
- router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => ({ correction: svc.resolveCorrection(req.params.id, body(req), req.actor) })));
+ router.patch('/corrections/:id', guard('reviews.entity.manage'), R((req) => {
+ const b = body(req);
+ // Accepting with a correction publishes a summary revision: a service needs that grant too.
+ if (req.actor.kind === 'service' && b.status === 'accepted' && (b.correction_note || b.summary)) {
+ const c = checkCapability(req.actor.claims, 'reviews.summary.publish');
+ if (!c.allowed) throw new svc.ReviewsError(403, c.code, c.reason);
+ }
+ return svc.resolveCorrection(req.params.id, b, req.actor);
+ }));
return router;
return router;
}
}
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
*
*
* /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
* /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
* /sitemap.xml sitemap index → /sitemaps/pages.xml, /sitemaps/entities-<n>.xml
* /sitemap.xml sitemap index → /sitemaps/pages.xml, /sitemaps/entities-<n>.xml
- * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1)
+ * /feed.atom published summaries (Atom), /feed.json (JSON Feed 1.1); one entry per published
+ * revision, so a correction is a new entry that starts with its note
* /llms.txt orientation for language models
* /llms.txt orientation for language models
*/
*/
const express = require('express');
const express = require('express');
@@ -53,7 +54,8 @@ function createMachine({ svc, config }) {
@@ -53,7 +54,8 @@ function createMachine({ svc, config }) {
id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,
id: `tag:openvibe.reviews,2026:summary/${summary.id}/revision/${summary.published_revision}`,
url: `${svc.entityUrl(entity)}#summary`,
url: `${svc.entityUrl(entity)}#summary`,
title: `${entity.name}: summary`,
title: `${entity.name}: summary`,
- summary: [rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)].join('\n').slice(0, 1000),
+ summary: [rev.meta && rev.meta.correction ? `Correction: ${rev.meta.correction.note}` : null, rev.content || '', ...(rev.fields.pros || []).map((p) => `Pro: ${p.text}`), ...(rev.fields.cons || []).map((p) => `Con: ${p.text}`)]
+ .filter((x) => x != null).join('\n').slice(0, 1000),
published: summary.revision_published_at,
published: summary.revision_published_at,
updated: summary.revision_published_at,
updated: summary.revision_published_at,
tags: [entity.kind],
tags: [entity.kind],
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* GET /e/:slug entity page: aggregate with inputs, summary, signals with provenance, discussion
* GET /e/:slug entity page: aggregate with inputs, summary, signals with provenance, discussion
* GET /e/:slug.json the same data as JSON
* GET /e/:slug.json the same data as JSON
* GET /e/:slug/history aggregate revisions, summary revisions, merges and splits, every signal, editorial log
* GET /e/:slug/history aggregate revisions, summary revisions, merges and splits, every signal, editorial log
- * GET /e/:slug/summary/:n one summary revision (editors: approve / reject)
+ * GET /e/:slug/summary/:n one summary revision (editors: approve / reject); corrections carry their note
* GET|POST /e/:slug/correct a signed-in person sends a correction to the editors
* GET|POST /e/:slug/correct a signed-in person sends a correction to the editors
* POST /e/:slug/discuss comment through OpenVibe.Community
* POST /e/:slug/discuss comment through OpenVibe.Community
* Editors: GET /editor, POST /editor/sync, GET|POST /editor/entities/new, GET|POST /e/:slug/edit,
* Editors: GET /editor, POST /editor/sync, GET|POST /editor/entities/new, GET|POST /e/:slug/edit,
@@ -47,12 +47,15 @@ function structuredData(p, config) {
@@ -47,12 +47,15 @@ function structuredData(p, config) {
const pub = p.summary && p.summary.published;
const pub = p.summary && p.summary.published;
if (pub && pub.points.some((x) => x.supported)) {
if (pub && pub.points.some((x) => x.supported)) {
const text = [pub.overview || '', ...pub.points.filter((x) => x.kind !== 'overview').map((x) => `${x.kind === 'pro' ? 'Pro' : 'Con'}: ${x.text}`)].join('\n').trim();
const text = [pub.overview || '', ...pub.points.filter((x) => x.kind !== 'overview').map((x) => `${x.kind === 'pro' ? 'Pro' : 'Con'}: ${x.text}`)].join('\n').trim();
- out.push(seo.structuredData.review({
+ const review = seo.structuredData.review({
url: `${en.url}#summary`, itemReviewed: { type, name: en.name, url: en.url },
url: `${en.url}#summary`, itemReviewed: { type, name: en.name, url: en.url },
author: { type: 'Organization', name: 'OpenVibe.Reviews editors', url: `${origin}/about` },
author: { type: 'Organization', name: 'OpenVibe.Reviews editors', url: `${origin}/about` },
- datePublished: p.summary.revision_published_at, body: text.slice(0, 5000),
+ datePublished: p.summary.published_at || p.summary.revision_published_at, body: text.slice(0, 5000),
publisher: { name: 'OpenVibe', url: 'https://openvibe.network' },
publisher: { name: 'OpenVibe', url: 'https://openvibe.network' },
- }));
+ });
+ // A later revision (an update or a correction) is the same review, modified.
+ if (review && p.summary.published_at && p.summary.revision_published_at !== p.summary.published_at) review.dateModified = p.summary.revision_published_at;
+ out.push(review);
}
}
return out.filter(Boolean);
return out.filter(Boolean);
}
}
@@ -75,6 +78,7 @@ function summaryFromForm(b) {
@@ -75,6 +78,7 @@ function summaryFromForm(b) {
pros: points('pro'), cons: points('con'),
pros: points('pro'), cons: points('con'),
expected_revision: b.expected_revision != null && b.expected_revision !== '' ? Number(b.expected_revision) : undefined,
expected_revision: b.expected_revision != null && b.expected_revision !== '' ? Number(b.expected_revision) : undefined,
message: b.message || null, publish: b.publish === '1',
message: b.message || null, publish: b.publish === '1',
+ correction_note: b.correction_note || null, correction_id: b.correction_id || null,
};
};
}
}
@@ -266,15 +270,21 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -266,15 +270,21 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
}
}
}));
}));
- function editPage(req, res, e, { status = 200, error = null, flash = null } = {}) {
- send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
+ /** An open correction request about `e` (the summary form then answers it). */
+ function openCorrectionFor(e, id) {
+ const c = id ? svc.correction(String(id)) : null;
+ return c && c.status === 'open' && svc.canonicalOf(c.entity_id) === e.id ? c : null;
+ }
+
+ function editPage(req, res, e, { status = 200, error = null, flash = null, correcting = null } = {}) {
+ send(req, res, status, views.editEntityPage({ page: svc.page(e, req.actor), error, flash, correcting, entities: svc.listEntities({ limit: 200 }).entities }), { title: `Edit ${e.name}`, robots: 'noindex, nofollow' });
}
}
router.get('/e/:slug/edit', (req, res) => {
router.get('/e/:slug/edit', (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
const e = locate(req, res, '/edit');
const e = locate(req, res, '/edit');
if (!e) return;
if (!e) return;
- editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null });
+ editPage(req, res, e, { flash: req.query.saved ? 'Saved.' : null, correcting: openCorrectionFor(e, req.query.correction) });
});
});
router.post('/e/:slug/edit', form, wrap(async (req, res) => {
router.post('/e/:slug/edit', form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
@@ -311,7 +321,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -311,7 +321,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);
res.redirect(303, `${svc.entityPath(e)}/edit?saved=1`);
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) throw err;
if (!err.status || err.status >= 500) throw err;
- editPage(req, res, e, { status: err.status, error: err.message });
+ editPage(req, res, e, { status: err.status, error: err.message, correcting: openCorrectionFor(e, (req.body || {}).correction_id) });
}
}
}));
}));
@@ -364,7 +374,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
@@ -364,7 +374,7 @@ function createPages({ svc, platform, sync, viewers, config, log = console }) {
router.post('/editor/corrections/:id', form, wrap(async (req, res) => {
router.post('/editor/corrections/:id', form, wrap(async (req, res) => {
if (!editorOnly(req, res)) return;
if (!editorOnly(req, res)) return;
const b = req.body || {};
const b = req.body || {};
- svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null }, req.actor);
+ svc.resolveCorrection(req.params.id, { status: b.status, note: b.note || null, correction_note: b.correction_note || null }, req.actor);
res.redirect(303, '/editor#corrections');
res.redirect(303, '/editor#corrections');
}));
}));
@@ -99,7 +99,7 @@ function aboutPage() {
@@ -99,7 +99,7 @@ function aboutPage() {
<h2 id="entities">Entities, merges and splits</h2>
<h2 id="entities">Entities, merges and splits</h2>
<p>Items are matched to entities by deterministic rules: a source bound to one entity, the item's URL, a GTIN, SKU or MPN. A name alone is never enough: an editor confirms it. A merge never rewrites anything, so a split restores the earlier attribution exactly; both are recorded with who, when and why.</p>
<p>Items are matched to entities by deterministic rules: a source bound to one entity, the item's URL, a GTIN, SKU or MPN. A name alone is never enough: an editor confirms it. A merge never rewrites anything, so a split restores the earlier attribution exactly; both are recorded with who, when and why.</p>
<h2 id="corrections">Corrections and discussion</h2>
<h2 id="corrections">Corrections and discussion</h2>
-<p>Anyone signed in can send a correction to the editors from an entity page. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p>
+<p>Anyone signed in can send a correction to the editors from an entity page. What you send, and who you are, stays with the editors. When an editor corrects a published summary, the correction is a new revision published with a note saying what was corrected; the earlier revisions stay readable in the summary's revision history. Discussion lives on <a href="https://openvibe.community">OpenVibe.Community</a> and is shown here, never copied.</p>
</section>`;
</section>`;
}
}
@@ -133,6 +133,20 @@ function citeLinks(cites) {
@@ -133,6 +133,20 @@ function citeLinks(cites) {
return html`${cites.map((c, i) => html`${i ? ' ' : ''}<a href="#${c.signal_id}" class="${c.ok ? 'rv-cite' : 'rv-cite rv-cite-gone'}" title="${c.ok ? 'cited signal' : `cited signal is ${c.status}`}">[${c.signal_id.slice(-6)}${c.ok ? '' : ` ${c.status}`}]</a>`)}`;
return html`${cites.map((c, i) => html`${i ? ' ' : ''}<a href="#${c.signal_id}" class="${c.ok ? 'rv-cite' : 'rv-cite rv-cite-gone'}" title="${c.ok ? 'cited signal' : `cited signal is ${c.status}`}">[${c.signal_id.slice(-6)}${c.ok ? '' : ` ${c.status}`}]</a>`)}`;
}
}
+/** The summary's public revision history: every revision readers may see, with each correction note. */
+function summaryHistory(en, s) {
+ const list = s.history || [];
+ if (!list.length) return '';
+ const corrections = list.filter((r) => r.correction).length;
+ return html`<h3 id="summary-history">Revision history</h3>
+<p class="rv-muted">${corrections ? `${corrections} correction${corrections === 1 ? '' : 's'}. ` : ''}Earlier revisions stay readable; a correction is a new revision, never an edit of an old one.</p>
+<ul class="rv-revisions">${list.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${tl(r.created_at)}${r.disclosure ? html` · ${r.disclosure}` : ''}${r.correction ? html`<br><strong>Correction:</strong> ${r.correction.note}` : ''}</li>`)}</ul>`;
+}
+
+function correctionNotice(r, at) {
+ return html`<p class="rv-notice rv-correction" role="status"><strong>Corrected</strong> ${t(at)}${r.correction.corrects ? ` (revision ${r.correction.corrects} corrected by revision ${r.number})` : ''}: ${r.correction.note}</p>`;
+}
+
function summarySection(p) {
function summarySection(p) {
const s = p.summary;
const s = p.summary;
const head = html`<h2 id="summary">Summary</h2>`;
const head = html`<h2 id="summary">Summary</h2>`;
@@ -146,13 +160,15 @@ function summarySection(p) {
@@ -146,13 +160,15 @@ function summarySection(p) {
const ov = r.points.find((x) => x.kind === 'overview');
const ov = r.points.find((x) => x.kind === 'overview');
return html`<section class="rv-summary">${head}
return html`<section class="rv-summary">${head}
${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by OpenVibe.Reviews editors. Revision ${String(r.number)}, published ${t(s.revision_published_at)}.</p>`}
${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by OpenVibe.Reviews editors. Revision ${String(r.number)}, published ${t(s.revision_published_at)}.</p>`}
+${r.correction ? correctionNotice(r, s.revision_published_at) : ''}
${s.flagged ? notice('warn', `Under review: ${s.flag_reason || 'a cited signal changed'} (${s.flagged_at ? s.flagged_at.slice(0, 10) : ''}). Points whose citations are marked below may no longer be supported.`) : ''}
${s.flagged ? notice('warn', `Under review: ${s.flag_reason || 'a cited signal changed'} (${s.flagged_at ? s.flagged_at.slice(0, 10) : ''}). Points whose citations are marked below may no longer be supported.`) : ''}
${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 2 }))}${ov ? html`<p class="rv-muted">Cites ${citeLinks(ov.citations)}</p>` : ''}</div>` : ''}
${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 2 }))}${ov ? html`<p class="rv-muted">Cites ${citeLinks(ov.citations)}</p>` : ''}</div>` : ''}
<div class="rv-proscons">
<div class="rv-proscons">
<div><h3>Pros</h3>${pros.length ? html`<ul>${pros.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
<div><h3>Pros</h3>${pros.length ? html`<ul>${pros.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
<div><h3>Cons</h3>${cons.length ? html`<ul>${cons.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
<div><h3>Cons</h3>${cons.length ? html`<ul>${cons.map((x) => html`<li${x.supported ? '' : raw(' class="rv-unsupported"')}>${x.text} ${citeLinks(x.citations)}</li>`)}</ul>` : html`<p class="rv-muted">None listed.</p>`}</div>
</div>
</div>
-<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p></section>`;
+<p class="rv-muted">A summary never carries a rating: the only numbers are the signals above and the aggregate computed from them.</p>
+${summaryHistory(p.entity, s)}</section>`;
}
}
function signalRow(s) {
function signalRow(s) {
@@ -233,7 +249,7 @@ function historyPage(h) {
@@ -233,7 +249,7 @@ function historyPage(h) {
<h2 id="aggregates">Aggregate revisions</h2>
<h2 id="aggregates">Aggregate revisions</h2>
${h.aggregates.length ? html`<div class="rv-scroll"><table class="rv-history"><thead><tr><th>Revision</th><th>Computed</th><th>Why</th><th>Result</th></tr></thead><tbody>${h.aggregates.map((a) => html`<tr><td>${String(a.revision)}</td><td>${tl(a.computed_at)}</td><td>${a.trigger.replace(/_/g, ' ')}</td><td>${aggregateLine(a)}${a.result ? html` <span class="rv-muted">(${String(a.result.inputs.length)} inputs)</span>` : ''}</td></tr>`)}</tbody></table></div>` : html`<p class="rv-muted">No aggregate has ever been computed: no qualifying signal yet.</p>`}
${h.aggregates.length ? html`<div class="rv-scroll"><table class="rv-history"><thead><tr><th>Revision</th><th>Computed</th><th>Why</th><th>Result</th></tr></thead><tbody>${h.aggregates.map((a) => html`<tr><td>${String(a.revision)}</td><td>${tl(a.computed_at)}</td><td>${a.trigger.replace(/_/g, ' ')}</td><td>${aggregateLine(a)}${a.result ? html` <span class="rv-muted">(${String(a.result.inputs.length)} inputs)</span>` : ''}</td></tr>`)}</tbody></table></div>` : html`<p class="rv-muted">No aggregate has ever been computed: no qualifying signal yet.</p>`}
<h2 id="summary">Summary revisions</h2>
<h2 id="summary">Summary revisions</h2>
-${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.message ? html` <span class="rv-muted">${r.message}</span>` : ''}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}
+${h.summary_revisions.length ? html`<ul>${h.summary_revisions.map((r) => html`<li><a href="${epath(en)}/summary/${String(r.number)}">Revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span> ${t(r.created_at)}${r.disclosure ? html` · ${r.disclosure.short}` : ''}${r.system ? html` · prepared after: ${r.system.reason}` : ''}${r.correction ? html`<br><strong>Correction:</strong> ${r.correction.note}` : (r.message ? html` <span class="rv-muted">${r.message}</span>` : '')}</li>`)}</ul>` : html`<p class="rv-muted">No summary revisions.</p>`}
<h2 id="merges">Merges and splits</h2>
<h2 id="merges">Merges and splits</h2>
${h.merges.length ? html`<ul>${h.merges.map((m) => html`<li><a href="${epath(m.from)}">${m.from.name}</a> merged into <a href="${epath(m.to)}">${m.to.name}</a> ${tl(m.merged_at)}${m.note ? html` — ${m.note}` : ''}${m.split_at ? html`; split again ${tl(m.split_at)}${m.split_note ? html` — ${m.split_note}` : ''}` : html` <span class="rv-tag">active</span>`}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
${h.merges.length ? html`<ul>${h.merges.map((m) => html`<li><a href="${epath(m.from)}">${m.from.name}</a> merged into <a href="${epath(m.to)}">${m.to.name}</a> ${tl(m.merged_at)}${m.note ? html` — ${m.note}` : ''}${m.split_at ? html`; split again ${tl(m.split_at)}${m.split_note ? html` — ${m.split_note}` : ''}` : html` <span class="rv-tag">active</span>`}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
<h2 id="signals">Every signal, including replaced and withdrawn ones</h2>
<h2 id="signals">Every signal, including replaced and withdrawn ones</h2>
@@ -247,6 +263,7 @@ function summaryRevisionPage(en, r, { editor }) {
@@ -247,6 +263,7 @@ function summaryRevisionPage(en, r, { editor }) {
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'History', url: `${epath(en)}/history` }, { name: `Summary revision ${r.number}` }])}
return html`${crumbs([{ name: 'Reviews', url: '/' }, { name: en.name, url: epath(en) }, { name: 'History', url: `${epath(en)}/history` }, { name: `Summary revision ${r.number}` }])}
<section><h1>Summary revision ${String(r.number)} <span class="rv-tag">${r.status}</span></h1>
<section><h1>Summary revision ${String(r.number)} <span class="rv-tag">${r.status}</span></h1>
${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by an editor ${t(r.created_at)}.</p>`}
${r.disclosure ? notice('ai', r.disclosure.long) : html`<p class="rv-muted">Written by an editor ${t(r.created_at)}.</p>`}
+${r.correction ? correctionNotice(r, r.created_at) : ''}
${r.system ? notice('warn', `Prepared automatically after: ${r.system.reason}. ${r.system.dropped_points && r.system.dropped_points.length ? `${r.system.dropped_points.length} point(s) were dropped because every signal they cited is gone.` : ''}`) : ''}
${r.system ? notice('warn', `Prepared automatically after: ${r.system.reason}. ${r.system.dropped_points && r.system.dropped_points.length ? `${r.system.dropped_points.length} point(s) were dropped because every signal they cited is gone.` : ''}`) : ''}
${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 1 }))}</div>` : ''}
${r.overview ? html`<div class="rv-content">${raw(ssr.renderMarkdown(r.overview, { headingShift: 1 }))}</div>` : ''}
<ul>${r.points.filter((p) => p.kind !== 'overview').map((p) => html`<li><strong>${p.kind === 'pro' ? 'Pro' : 'Con'}:</strong> ${p.text} ${citeLinks(p.citations)}</li>`)}</ul>
<ul>${r.points.filter((p) => p.kind !== 'overview').map((p) => html`<li><strong>${p.kind === 'pro' ? 'Pro' : 'Con'}:</strong> ${p.text} ${citeLinks(p.citations)}</li>`)}</ul>
@@ -261,7 +278,7 @@ function correctionPage(en, { values = {}, error = null, done = false } = {}) {
@@ -261,7 +278,7 @@ function correctionPage(en, { values = {}, error = null, done = false } = {}) {
<section><h1>Suggest a correction for ${en.name}</h1>
<section><h1>Suggest a correction for ${en.name}</h1>
${done ? notice('ok', 'Thank you. Your correction is in the editors\' queue.') : ''}
${done ? notice('ok', 'Thank you. Your correction is in the editors\' queue.') : ''}
${error ? notice('error', error) : ''}
${error ? notice('error', error) : ''}
-<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. Corrections are read by editors; they are not published.</p>
+<p>Tell the editors what is wrong: a signal attributed to the wrong thing, a source that should not count, a summary point the cited signals do not support. What you write, and who you are, stays with the editors. If they correct the summary, they publish a correction note saying what changed, next to the earlier revision.</p>
<form method="post" action="${epath(en)}/correct" class="rv-form">
<form method="post" action="${epath(en)}/correct" class="rv-form">
<label>About <select name="target_type">${['entity', 'signal', 'summary', 'aggregate', 'alias'].map((x) => html`<option value="${x}"${values.target_type === x ? raw(' selected') : ''}>${x}</option>`)}</select></label>
<label>About <select name="target_type">${['entity', 'signal', 'summary', 'aggregate', 'alias'].map((x) => html`<option value="${x}"${values.target_type === x ? raw(' selected') : ''}>${x}</option>`)}</select></label>
<label>Which one (optional: a signal id such as sig_…) <input type="text" name="target_id" maxlength="100" value="${values.target_id || ''}"></label>
<label>Which one (optional: a signal id such as sig_…) <input type="text" name="target_id" maxlength="100" value="${values.target_id || ''}"></label>
@@ -284,7 +301,11 @@ ${pending.length ? html`<ul>${pending.map((x) => html`<li><a href="${epath(x.ent
@@ -284,7 +301,11 @@ ${pending.length ? html`<ul>${pending.map((x) => html`<li><a href="${epath(x.ent
${flagged.length ? html`<ul>${flagged.map((s) => html`<li><a href="${epath(s.entity)}">${s.entity.name}</a>: ${s.flag_reason}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
${flagged.length ? html`<ul>${flagged.map((s) => html`<li><a href="${epath(s.entity)}">${s.entity.name}</a>: ${s.flag_reason}</li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
<h2 id="corrections">Open corrections</h2>
<h2 id="corrections">Open corrections</h2>
${corrections.length ? html`<ul>${corrections.map((c) => html`<li><a href="${epath(c.entity)}">${c.entity.name}</a> · ${c.target_type}${c.target_id ? ` ${c.target_id}` : ''} · ${t(c.created_at)}<blockquote>${c.body}</blockquote>${safeUrl(c.evidence_url) ? html`<a href="${safeUrl(c.evidence_url)}" rel="nofollow noopener">evidence</a>` : ''}
${corrections.length ? html`<ul>${corrections.map((c) => html`<li><a href="${epath(c.entity)}">${c.entity.name}</a> · ${c.target_type}${c.target_id ? ` ${c.target_id}` : ''} · ${t(c.created_at)}<blockquote>${c.body}</blockquote>${safeUrl(c.evidence_url) ? html`<a href="${safeUrl(c.evidence_url)}" rel="nofollow noopener">evidence</a>` : ''}
-<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-inline"><input type="text" name="note" placeholder="Note" maxlength="500"> <button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
+<form method="post" action="/editor/corrections/${e(c.id)}" class="rv-form">
+${c.summary_published ? html`<label>Correction note, published with a new summary revision (required to accept) <input type="text" name="correction_note" maxlength="2000"></label>
+<p class="rv-muted">Accepting publishes the current summary text again as a correction revision with this note. To change the text as well, <a href="${epath(c.entity)}/edit?correction=${e(c.id)}#summary-form">correct the summary</a>.</p>` : ''}
+<label>Note for editors (not published) <input type="text" name="note" maxlength="500"></label>
+<button type="submit" name="status" value="accepted">Accepted</button> <button type="submit" name="status" value="rejected">Rejected</button></form></li>`)}</ul>` : html`<p class="rv-muted">None.</p>`}
</section>`;
</section>`;
}
}
@@ -308,7 +329,7 @@ function newEntityPage({ values = {}, error = null } = {}) {
@@ -308,7 +329,7 @@ function newEntityPage({ values = {}, error = null } = {}) {
<button type="submit">Create</button></form></section>`;
<button type="submit">Create</button></form></section>`;
}
}
-function editEntityPage({ page: p, error = null, flash = null, entities = [] }) {
+function editEntityPage({ page: p, error = null, flash = null, correcting = null, entities = [] }) {
const en = p.entity;
const en = p.entity;
const allSignals = p.signals;
const allSignals = p.signals;
const sel = (name, chosen = []) => html`<select name="${name}" multiple size="4">${allSignals.map((s) => html`<option value="${s.signal_id}"${chosen.includes(s.signal_id) ? raw(' selected') : ''}>${s.signal_id.slice(-8)} · ${s.source_key} · ${signalValue(s)}</option>`)}</select>`;
const sel = (name, chosen = []) => html`<select name="${name}" multiple size="4">${allSignals.map((s) => html`<option value="${s.signal_id}"${chosen.includes(s.signal_id) ? raw(' selected') : ''}>${s.signal_id.slice(-8)} · ${s.source_key} · ${signalValue(s)}</option>`)}</select>`;
@@ -336,12 +357,14 @@ function editEntityPage({ page: p, error = null, flash = null, entities = [] })
@@ -336,12 +357,14 @@ function editEntityPage({ page: p, error = null, flash = null, entities = [] })
<h2>Summary</h2>
<h2>Summary</h2>
<p class="rv-muted">Every pro and con cites the signals it rests on. A summary carries no rating. Saving creates a new revision; "Save and publish" publishes it.</p>
<p class="rv-muted">Every pro and con cites the signals it rests on. A summary carries no rating. Saving creates a new revision; "Save and publish" publishes it.</p>
-<form method="post" action="${epath(en)}/summary" class="rv-form">
+<form method="post" action="${epath(en)}/summary" class="rv-form" id="summary-form">
+${correcting ? html`<div class="rv-notice rv-correction"><p>Correcting the summary in answer to a reader's correction request (${correcting.target_type}${correcting.target_id ? ` ${correcting.target_id}` : ''}):</p><blockquote>${correcting.body}</blockquote><p class="rv-muted">Publishing accepts the request. The request stays with the editors; the correction note below is what readers see.</p></div><input type="hidden" name="correction_id" value="${correcting.id}">` : ''}
<label>Overview (Markdown) <textarea name="overview" rows="6" maxlength="6000">${cur ? cur.overview : ''}</textarea></label>
<label>Overview (Markdown) <textarea name="overview" rows="6" maxlength="6000">${cur ? cur.overview : ''}</textarea></label>
<label>The overview cites ${sel('overview_signals', ov ? ov.citations.filter((c) => c.ok).map((c) => c.signal_id) : [])}</label>
<label>The overview cites ${sel('overview_signals', ov ? ov.citations.filter((c) => c.ok).map((c) => c.signal_id) : [])}</label>
<fieldset><legend>Pros</legend>${rows('pro', pros)}</fieldset>
<fieldset><legend>Pros</legend>${rows('pro', pros)}</fieldset>
<fieldset><legend>Cons</legend>${rows('con', cons)}</fieldset>
<fieldset><legend>Cons</legend>${rows('con', cons)}</fieldset>
<label>Revision note <input type="text" name="message" maxlength="500"></label>
<label>Revision note <input type="text" name="message" maxlength="500"></label>
+${cur ? html`<label>Correction note (public; fill it in only to correct the published summary: the revision is published at once and the note stays in its history) <input type="text" name="correction_note" maxlength="2000"${correcting ? raw(' required minlength="10"') : ''}></label>` : ''}
<input type="hidden" name="expected_revision" value="${String(p.summary ? p.summary.head_revision : 0)}">
<input type="hidden" name="expected_revision" value="${String(p.summary ? p.summary.head_revision : 0)}">
<button type="submit" name="publish" value="0">Save</button> <button type="submit" name="publish" value="1">Save and publish</button></form>
<button type="submit" name="publish" value="0">Save</button> <button type="submit" name="publish" value="1">Save and publish</button></form>
${p.summary && p.summary.pending && p.summary.pending.length ? html`<p>Waiting for review: ${p.summary.pending.map((r, i) => html`${i ? ', ' : ''}<a href="${epath(en)}/summary/${String(r.number)}">revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span>`)}</p>` : ''}
${p.summary && p.summary.pending && p.summary.pending.length ? html`<p>Waiting for review: ${p.summary.pending.map((r, i) => html`${i ? ', ' : ''}<a href="${epath(en)}/summary/${String(r.number)}">revision ${String(r.number)}</a> <span class="rv-tag">${r.status}</span>`)}</p>` : ''}
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
*
*
* Every write runs in one SQLite transaction together with the events it causes (transactional
* Every write runs in one SQLite transaction together with the events it causes (transactional
* outbox): reviews.entity.merged|split, reviews.signal.added|removed,
* outbox): reviews.entity.merged|split, reviews.signal.added|removed,
- * reviews.summary.published|updated|unpublished, and the Search index events
- * reviews.index_document.upserted|deleted.
+ * reviews.summary.published|updated|unpublished (a correction is an `updated` carrying
+ * `correction`), and the Search index events reviews.index_document.upserted|deleted.
*
*
* Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).
* Methods take an actor (server/reviews/access.js) and throw ReviewsError (status + stable code).
*/
*/
@@ -34,8 +34,11 @@ const SUMMARY_WORKFLOW = 'reviews.summarize_entity';
@@ -34,8 +34,11 @@ const SUMMARY_WORKFLOW = 'reviews.summarize_entity';
const MAX_POINTS = 12;
const MAX_POINTS = 12;
const MAX_POINT = 600;
const MAX_POINT = 600;
const MAX_OVERVIEW = 6000;
const MAX_OVERVIEW = 6000;
+const MIN_CORRECTION_NOTE = 10;
+const MAX_CORRECTION_NOTE = 2000;
// A summary is text with citations. Nothing in it can carry a score.
// A summary is text with citations. Nothing in it can carry a score.
-const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note']);
+const SUMMARY_KEYS = new Set(['overview', 'pros', 'cons', 'overview_signals', 'expected_revision', 'expectedRevision', 'message', 'publish', 'workflow', 'stub_provider', 'stubProvider', 'note', 'correction_note', 'correction_id']);
+const BODY_KEYS = ['overview', 'overview_signals', 'pros', 'cons'];
const RATING_KEY_RE = /(rating|stars?|score|grade|verdict_value)/i;
const RATING_KEY_RE = /(rating|stars?|score|grade|verdict_value)/i;
// Numeric ratings in AI text ("4.5/5", "8 out of 10", "★★★★"): AI output never states a rating.
// Numeric ratings in AI text ("4.5/5", "8 out of 10", "★★★★"): AI output never states a rating.
const RATING_TEXT_RE = /(\b\d+(?:[.,]\d+)?\s*(?:\/|out of)\s*(?:5|10|100)\b)|[★☆⭐]|\b\d+(?:[.,]\d+)?\s*stars?\b/i;
const RATING_TEXT_RE = /(\b\d+(?:[.,]\d+)?\s*(?:\/|out of)\s*(?:5|10|100)\b)|[★☆⭐]|\b\d+(?:[.,]\d+)?\s*stars?\b/i;
@@ -332,6 +335,90 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -332,6 +335,90 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
for (const p of summaryPoints(rev)) for (const id of p.signals) q.insertCitation.run(summary.id, rev.number, p.key, id);
for (const p of summaryPoints(rev)) for (const id of p.signals) q.insertCitation.run(summary.id, rev.number, p.key, id);
}
}
+ function expectedOf(input, head) {
+ if (input.expected_revision != null && input.expected_revision !== '') return Number(input.expected_revision);
+ if (input.expectedRevision != null && input.expectedRevision !== '') return Number(input.expectedRevision);
+ return head;
+ }
+
+ function createRevision(args) {
+ try {
+ return revisions.create(args).revision;
+ } catch (err) {
+ if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
+ throw err;
+ }
+ }
+
+ // ── Corrections of a published summary ───────────────────
+ const wantsCorrection = (input) => [input.correction_note, input.correction_id].some((v) => v != null && String(v).trim() !== '');
+
+ /** The correction note is public text: what was wrong and what changed. */
+ function checkCorrectionNote(v) {
+ const s = String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
+ if (s.length < MIN_CORRECTION_NOTE) fail(422, 'correction.note_required', `A correction says what was corrected, in a note readers will see (at least ${MIN_CORRECTION_NOTE} characters)`);
+ if (s.length > MAX_CORRECTION_NOTE) fail(422, 'correction.invalid', `A correction note is at most ${MAX_CORRECTION_NOTE} characters`);
+ return s;
+ }
+
+ /**
+ * Who answers for a correction revision: the correcting editor joins the published revision's
+ * authors. AI-drafted text a person corrected is AI-assisted (hybrid), never "written by a person".
+ */
+ function correctionAuthorship(base, who) {
+ const authors = [...new Set([...((base && base.authors) || []), who])];
+ if (base && (base.mode === 'ai' || base.mode === 'hybrid') && base.workflow) {
+ return authorship.record({ mode: 'hybrid', authors, workflow: base.workflow, stubProvider: !!base.stubProvider });
+ }
+ return authorship.record({ mode: 'human', authors });
+ }
+
+ /** The published text as summary input (a correction that changes no words, e.g. after a signal fix). */
+ function carryForward(rev) {
+ const f = rev.fields || {};
+ return { overview: rev.content || '', overview_signals: f.overview_signals || [], pros: f.pros || [], cons: f.cons || [] };
+ }
+
+ /**
+ * Corrects a published summary: a new immutable revision carrying the public correction note
+ * (who: its author, the editor; when: its time), approved by that editor and published at once.
+ * Earlier revisions stay in the public history. Without new text the published text is carried
+ * forward. An open correction request it answers is accepted in the same transaction; what the
+ * request said and who sent it stay with the editors.
+ */
+ function correctSummary(e, input, { note, requestId = null, resolutionNote = null }, actor, who) {
+ const text = checkCorrectionNote(note);
+ const summary = q.summary.get(e.id);
+ if (!summary || summary.state !== 'published' || !summary.published_revision) fail(409, 'summary.not_published', 'Only a published summary is corrected; save and publish a revision instead');
+ let request = null;
+ if (requestId != null && String(requestId).trim() !== '') {
+ request = q.correction.get(String(requestId).trim());
+ if (!request) fail(404, 'correction.not_found', 'No such correction');
+ if (canonicalOf(request.entity_id) !== e.id) fail(409, 'correction.other_entity', 'That correction is about another entity');
+ if (request.status !== 'open') fail(409, 'correction.closed', `Already ${request.status}`);
+ }
+ const pub = revisions.get(summary.id, summary.published_revision);
+ const body = checkSummaryInput(e.id, BODY_KEYS.some((k) => input[k] !== undefined) ? input : carryForward(pub));
+ const revision = createRevision({
+ entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
+ meta: {
+ authorship: correctionAuthorship(pub.meta && pub.meta.authorship, who),
+ correction: { note: text, request: request ? request.id : null, corrects: pub.number },
+ },
+ author: who, message: input.message ? String(input.message).slice(0, 500) : `Correction: ${text.slice(0, 200)}`, allowUnchanged: true,
+ });
+ writeCitations(summary, revision);
+ reviews.record({ entityId: summary.id, revision: revision.number, reviewer: who, decision: 'approved', note: 'correction' });
+ if (request) {
+ q.resolveCorrection.run('accepted', who, resolutionNote, now(), request.id);
+ audit(actor, 'correction.accepted', e.id, request.id, { note: resolutionNote, summary_revision: revision.number });
+ }
+ audit(actor, 'summary.corrected', e.id, summary.id, { revision: revision.number, corrects: pub.number, correction_id: request ? request.id : null });
+ svc.publishSummary(e.id, { revision: revision.number }, actor);
+ const fresh = q.summary.get(e.id);
+ return { summary: fresh, revision: summaryRevisionView(fresh, revisions.get(summary.id, revision.number), e.id), published: true, correction: request ? q.correction.get(request.id) : null };
+ }
+
function revisionStatus(summary, rev) {
function revisionStatus(summary, rev) {
const review = reviews.latest(summary.id, rev.number);
const review = reviews.latest(summary.id, rev.number);
const rec = rev.meta && rev.meta.authorship;
const rec = rev.meta && rev.meta.authorship;
@@ -829,10 +916,35 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -829,10 +916,35 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
disclosure: rec ? authorship.disclosure(rec, review) : null,
disclosure: rec ? authorship.disclosure(rec, review) : null,
review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
review: review ? { decision: review.decision, reviewed_at: review.reviewedAt } : null,
system: rev.meta && rev.meta.system ? rev.meta.system : null,
system: rev.meta && rev.meta.system ? rev.meta.system : null,
+ correction: correctionView(rev),
author: rev.author, message: rev.message, created_at: rev.createdAt,
author: rev.author, message: rev.message, created_at: rev.createdAt,
};
};
}
}
+ /** The public side of a correction: the note, which revision it corrects, and whether a reader asked for it. */
+ function correctionView(rev) {
+ const c = rev && rev.meta && rev.meta.correction;
+ return c ? { note: c.note, corrects: c.corrects, requested: !!c.request } : null;
+ }
+
+ /** The summary's history as readers may see it, newest first (entity page). */
+ function publicHistory(summary) {
+ if (!summary) return [];
+ return revisions.list(summary.id, { limit: 200 }).filter((r) => publicRevision(summary, r)).map((r) => {
+ const d = r.meta && r.meta.authorship ? authorship.disclosure(r.meta.authorship, reviews.latest(summary.id, r.number)) : null;
+ return { number: r.number, status: revisionStatus(summary, r), created_at: r.createdAt, disclosure: d ? d.short : null, correction: correctionView(r) };
+ });
+ }
+
+ // Audit detail readers never see: which correction request it was, what it said, how editors resolved it.
+ function auditDetail(r, editor) {
+ const d = parse(r.detail, {});
+ if (editor) return d;
+ if (/^correction\./.test(r.action)) return {};
+ const { correction_id: _hidden, ...rest } = d;
+ return rest;
+ }
+
// ── Public API ───────────────────────────────────────────
// ── Public API ───────────────────────────────────────────
const svc = {
const svc = {
access, ReviewsError, entityPath, entityUrl, canonicalOf, closure,
access, ReviewsError, entityPath, entityUrl, canonicalOf, closure,
@@ -889,6 +1001,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -889,6 +1001,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
published_at: toIso(summary.published_at), revision_published_at: toIso(summary.revision_published_at),
published_at: toIso(summary.published_at), revision_published_at: toIso(summary.revision_published_at),
published: pub ? summaryRevisionView(summary, pub, e.id) : null,
published: pub ? summaryRevisionView(summary, pub, e.id) : null,
pending: editor ? pendingRevisions(summary).map((p) => summaryRevisionView(summary, p.rev, e.id)) : pendingRevisions(summary).length,
pending: editor ? pendingRevisions(summary).map((p) => summaryRevisionView(summary, p.rev, e.id)) : pendingRevisions(summary).length,
+ history: publicHistory(summary),
} : null,
} : null,
open_corrections: q.openCorrectionsOf.get(e.id).n,
open_corrections: q.openCorrectionsOf.get(e.id).n,
decision: decide(e),
decision: decide(e),
@@ -912,8 +1025,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -912,8 +1025,8 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
})),
})),
signals: allSignalsIn(closure(e.id)).map(signalView),
signals: allSignalsIn(closure(e.id)).map(signalView),
audit: q.auditOf.all(e.id, e.id).map((r) => ({
audit: q.auditOf.all(e.id, e.id).map((r) => ({
- id: r.id, at: toIso(r.at), action: r.action, target: r.target,
- actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: parse(r.detail, {}),
+ id: r.id, at: toIso(r.at), action: r.action, target: editor || !/^correction\./.test(r.action) ? r.target : null,
+ actor: editor ? r.actor : (/^usr_/.test(r.actor) ? 'an editor' : r.actor), detail: auditDetail(r, editor),
})),
})),
};
};
},
},
@@ -1245,27 +1358,24 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1245,27 +1358,24 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
trustMap,
trustMap,
// Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
// Summaries (reviews.summary.publish | reviews.summary.propose) ----------------------
- /** An editor writes a summary revision; publish: true publishes it at once. */
+ /**
+ * An editor writes a summary revision; publish: true publishes it at once. With a
+ * correction_note (and optionally the correction_id of a reader's request it answers) the
+ * revision corrects the published summary: it carries the note and is published at once.
+ */
writeSummary(ref, input = {}, actor) {
writeSummary(ref, input = {}, actor) {
const who = requireEditorPerson(actor, 'Writing a summary');
const who = requireEditorPerson(actor, 'Writing a summary');
return tx(() => {
return tx(() => {
const e = entityOrFail(ref);
const e = entityOrFail(ref);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
if (e.state !== 'active') fail(409, 'entity.not_active', `This entity is ${e.state}`);
+ if (wantsCorrection(input)) return correctSummary(e, input, { note: input.correction_note, requestId: input.correction_id }, actor, who);
const body = checkSummaryInput(e.id, input);
const body = checkSummaryInput(e.id, input);
const summary = ensureSummary(e.id);
const summary = ensureSummary(e.id);
- const head = revisions.headNumber(summary.id);
- const expected = input.expected_revision != null ? Number(input.expected_revision) : input.expectedRevision != null ? Number(input.expectedRevision) : head;
- let revision;
- try {
- ({ revision } = revisions.create({
- entityId: summary.id, expectedRevision: expected, content: body.content, fields: body.fields,
- meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
- message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
- }));
- } catch (err) {
- if (err && err.code === 'revision.conflict') fail(412, 'revision.conflict', err.message, { expected: err.expected, current: err.current });
- throw err;
- }
+ const revision = createRevision({
+ entityId: summary.id, expectedRevision: expectedOf(input, revisions.headNumber(summary.id)), content: body.content, fields: body.fields,
+ meta: { authorship: authorship.record({ mode: 'human', authors: [who] }) }, author: who,
+ message: input.message ? String(input.message).slice(0, 500) : null, allowUnchanged: true,
+ });
writeCitations(summary, revision);
writeCitations(summary, revision);
audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });
audit(actor, 'summary.revision', e.id, summary.id, { revision: revision.number });
let published = null;
let published = null;
@@ -1353,7 +1463,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1353,7 +1463,7 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
product: 'reviews', type: 'summary', action, id: summary.id, revision: n,
actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
actor: hooks.subjectRef(actorId(actor)), decision, now: now(),
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: false, visibility: 'public', canonical_url: entityUrl(fresh), publication_state: 'published', indexability: hooks.searchIndexability(decision) },
document: { owner: 'reviews', type: 'summary', id: summary.id, revision: doc ? doc.revision : 0, deleted: false, visibility: 'public', canonical_url: entityUrl(fresh), publication_state: 'published', indexability: hooks.searchIndexability(decision) },
- extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null },
+ extra: { entity_id: e.id, entity_slug: fresh.slug, authorship: rec ? hooks.AUTHORSHIP[rec.mode] : null, ...(rev.meta && rev.meta.correction ? { correction: { note: rev.meta.correction.note, corrects: rev.meta.correction.corrects } } : {}) },
}));
}));
}
}
return q.summary.get(e.id);
return q.summary.get(e.id);
@@ -1431,18 +1541,40 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
@@ -1431,18 +1541,40 @@ function createReviewsService({ db, stores, outbox, config, now = () => Date.now
return q.correction.get(id);
return q.correction.get(id);
});
});
},
},
- openCorrections() { return q.openCorrections.all().map((c) => ({ ...c, entity: q.entity.get(c.entity_id) })); },
+ openCorrections() {
+ return q.openCorrections.all().map((c) => {
+ const entity = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);
+ const s = entity && entity.state === 'active' ? q.summary.get(entity.id) : null;
+ return { ...c, entity, summary_published: !!(s && s.state === 'published') };
+ });
+ },
correction(id) { return q.correction.get(String(id)) || null; },
correction(id) { return q.correction.get(String(id)) || null; },
- resolveCorrection(id, { status, note = null } = {}, actor) {
- requireEditorPerson(actor, 'Resolving a correction');
+ /**
+ * An editor accepts or rejects a correction request. `note` stays with the editors. Accepting
+ * a request about an entity whose summary is published corrects that summary: a new revision
+ * with the public `correction_note` (and the new text in `summary`, or the published text
+ * carried forward). Rejecting creates nothing.
+ */
+ resolveCorrection(id, { status, note = null, correction_note: correctionNote = null, summary: body = null } = {}, actor) {
+ const who = requireEditorPerson(actor, 'Resolving a correction');
if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');
if (!['accepted', 'rejected'].includes(status)) fail(422, 'correction.invalid', 'status is accepted or rejected');
+ if (body != null && (typeof body !== 'object' || Array.isArray(body))) fail(422, 'summary.invalid', 'summary is { overview, overview_signals, pros, cons }');
return tx(() => {
return tx(() => {
const c = q.correction.get(String(id));
const c = q.correction.get(String(id));
if (!c) fail(404, 'correction.not_found', 'No such correction');
if (!c) fail(404, 'correction.not_found', 'No such correction');
if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
if (c.status !== 'open') fail(409, 'correction.closed', `Already ${c.status}`);
- q.resolveCorrection.run(status, actorId(actor), note ? String(note).slice(0, 2000) : null, now(), c.id);
- audit(actor, `correction.${status}`, c.entity_id, c.id, { note });
- return q.correction.get(c.id);
+ const internal = note ? String(note).slice(0, 2000) : null;
+ if (status === 'accepted') {
+ const e = q.entity.get(canonicalOf(c.entity_id) || c.entity_id);
+ const s = e && e.state === 'active' ? q.summary.get(e.id) : null;
+ if (s && s.state === 'published') {
+ const out = correctSummary(e, { ...(body || {}) }, { note: correctionNote, requestId: c.id, resolutionNote: internal }, actor, who);
+ return { correction: out.correction, revision: out.revision };
+ }
+ }
+ q.resolveCorrection.run(status, who, internal, now(), c.id);
+ audit(actor, `correction.${status}`, c.entity_id, c.id, { note: internal });
+ return { correction: q.correction.get(c.id), revision: null };
});
});
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
+'use strict';
+/**
+ * Corrections: accepting a reader's correction of a published summary (or an editor correcting it
+ * on their own) creates a new immutable summary revision that carries the public correction note,
+ * is published at once, and shows on the entity page without JavaScript next to the earlier
+ * revisions, which stay readable. The request's text, the internal note and who sent it never
+ * reach a public surface. Rejecting creates nothing; only editors decide.
+ */
+const assert = require('assert');
+const {
+ boot, req, suite, steamItem, importItem, createEntity, outbox, cookieFor, editorToken, readerToken, serviceToken,
+ EDITOR, READER,
+} = require('./helpers');
+
+const t = suite('corrections');
+const noScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
+const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+const revCount = (h, summaryId) => h.db.prepare('SELECT COUNT(*) AS n FROM review_summary_revisions WHERE entity_id = ?').get(summaryId).n;
+
+const OLD = 'Every sampled Steam reviewer recommends it without reservation.';
+const NEW = 'Three of the four sampled Steam reviewers recommend it; one does not.';
+const NOTE = 'The overview said every sampled reviewer recommends it; one of the four does not.';
+const REQUEST = 'The summary claims unanimous praise but a negative review is listed right below it.';
+const INTERNAL = 'internal: checked the signal table, reader is right';
+
+async function setup(h) {
+ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
+ for (const v of [true, true, true, false]) await importItem(h, steamItem({ votedUp: v }));
+ const sigs = h.db.prepare("SELECT id, recommended FROM review_signals WHERE entity_id = ? AND status = 'active' ORDER BY id").all(e.id);
+ const pos = sigs.filter((s) => s.recommended === 1).map((s) => s.id);
+ const neg = sigs.filter((s) => s.recommended === 0).map((s) => s.id);
+ const w = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
+ overview: OLD, overview_signals: pos, pros: [{ text: 'Recommended by the sample', signals: pos }], publish: true,
+ } });
+ assert.strictEqual(w.status, 201, w.text);
+ const summaryId = w.json.summary.id;
+ return { e, pos, neg, summaryId };
+}
+
+async function submit(h, e, body = REQUEST) {
+ const r = await req(h, 'POST', `/e/${e.slug}/correct`, { cookie: cookieFor(readerToken()), form: { target_type: 'summary', body, evidence_url: 'https://store.steampowered.com/app/620/' } });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ return h.db.prepare('SELECT * FROM review_corrections WHERE body = ?').get(body);
+}
+
+t('accepting a correction publishes a new revision with its note; the page shows it and the history without JavaScript', async () => {
+ const h = await boot();
+ try {
+ const { e, pos, neg, summaryId } = await setup(h);
+ const c = await submit(h, e);
+ assert.strictEqual(c.submitted_by, READER);
+
+ // Only an editor who is a person decides, and nothing is created by a refused attempt.
+ const patch = (token, body, headers) => req(h, 'PATCH', `/api/v1/corrections/${c.id}`, { token, body, headers });
+ const accept = { status: 'accepted', correction_note: NOTE };
+ assert.strictEqual((await patch(readerToken(), accept)).status, 403);
+ assert.strictEqual((await req(h, 'POST', `/editor/corrections/${c.id}`, { cookie: cookieFor(readerToken()), form: { status: 'accepted', correction_note: NOTE } })).status, 403);
+ assert.strictEqual((await req(h, 'POST', `/editor/corrections/${c.id}`, { form: { status: 'accepted', correction_note: NOTE } })).status, 401);
+ assert.strictEqual((await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage', 'reviews.summary.publish'] }), accept)).status, 403, 'a service on its own');
+ assert.strictEqual((await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage', 'reviews.summary.publish'] }), accept, { 'X-OV-Subject': READER })).status, 403, 'a service acting for a reader');
+ const noPublish = await patch(serviceToken({ client: 'tools', cap: ['reviews.entity.manage'] }), accept, { 'X-OV-Subject': EDITOR });
+ assert.strictEqual(noPublish.status, 403, 'publishing a correction revision needs reviews.summary.publish');
+ assert.strictEqual(noPublish.json.code, 'capability.denied');
+ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: readerToken(), body: { overview: NEW, overview_signals: pos, correction_note: NOTE } })).status, 403);
+ // A correction of a published summary says what changed.
+ const bare = await patch(editorToken(), { status: 'accepted' });
+ assert.strictEqual(bare.status, 422);
+ assert.strictEqual(bare.json.code, 'correction.note_required');
+ assert.strictEqual(revCount(h, summaryId), 1);
+ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c.id).status, 'open');
+
+ const ok = await patch(editorToken(), {
+ status: 'accepted', note: INTERNAL, correction_note: NOTE,
+ summary: { overview: NEW, overview_signals: [...pos, ...neg], pros: [{ text: 'Recommended by most of the sample', signals: pos }], cons: [{ text: 'Not by everyone', signals: neg }] },
+ });
+ assert.strictEqual(ok.status, 200, ok.text);
+ assert.strictEqual(ok.json.correction.status, 'accepted');
+ assert.strictEqual(ok.json.correction.resolved_by, EDITOR);
+ assert.strictEqual(ok.json.revision.number, 2);
+ assert.strictEqual(ok.json.revision.status, 'published');
+ assert.deepStrictEqual(ok.json.revision.correction, { note: NOTE, corrects: 1, requested: true });
+ assert.strictEqual(ok.json.revision.author, EDITOR, 'who: the editor who corrected it');
+
+ // Immutable revisions: the old text is still revision 1; the correction is revision 2.
+ const rows = h.db.prepare('SELECT number, content, meta, author FROM review_summary_revisions WHERE entity_id = ? ORDER BY number').all(summaryId);
+ assert.deepStrictEqual(rows.map((r) => [r.number, r.content]), [[1, OLD], [2, NEW]]);
+ assert.strictEqual(JSON.parse(rows[1].meta).correction.note, NOTE);
+ assert.throws(() => h.db.prepare('UPDATE review_summary_revisions SET content = ? WHERE entity_id = ? AND number = 2').run('rewritten', summaryId));
+
+ // The public page, without JavaScript: the corrected text, the note, and the history.
+ const page = await req(h, 'GET', `/e/${e.slug}`);
+ assert.strictEqual(page.status, 200);
+ const text = noScripts(page.text);
+ assert.ok(text.includes(NEW), 'current text reflects the correction');
+ assert.ok(!text.includes(OLD), 'the corrected text is not the current text');
+ assert.match(text, /<strong>Corrected<\/strong>/);
+ assert.match(text, /Revision history/);
+ assert.ok(text.includes(`Correction:</strong> ${NOTE}`));
+ assert.ok(text.includes(`href="/e/${e.slug}/summary/2"`) && text.includes(`href="/e/${e.slug}/summary/1"`), 'both revisions in the history');
+ const r1 = await req(h, 'GET', `/e/${e.slug}/summary/1`);
+ assert.strictEqual(r1.status, 200);
+ assert.ok(noScripts(r1.text).includes(OLD), 'the old revision stays readable');
+ assert.match(r1.text, /superseded/);
+ const r2 = noScripts((await req(h, 'GET', `/e/${e.slug}/summary/2`)).text);
+ assert.ok(r2.includes(NOTE) && r2.includes(NEW));
+ const hist = noScripts((await req(h, 'GET', `/e/${e.slug}/history`)).text);
+ assert.ok(hist.includes(NOTE));
+
+ // Structured data, feeds, events and Search follow the corrected revision.
+ const review = jsonLd(page.text).find((x) => x['@type'] === 'Review');
+ assert.ok(review.reviewBody.includes(NEW) && !review.reviewBody.includes(OLD));
+ assert.ok(review.dateModified, 'the Review says it was modified');
+ const feed = JSON.parse((await req(h, 'GET', '/feed.json')).text);
+ assert.ok(feed.items[0].summary.startsWith(`Correction: ${NOTE}\n${NEW}`), JSON.stringify(feed.items[0]));
+ const events = outbox(h);
+ const upd = events.filter((x) => x.event_type === 'reviews.summary.updated');
+ assert.strictEqual(upd.length, 1);
+ assert.deepStrictEqual(upd[0].payload.correction, { note: NOTE, corrects: 1 });
+ assert.strictEqual(upd[0].subject.revision, 2);
+ const doc = events.filter((x) => x.event_type === 'reviews.index_document.upserted').pop().payload;
+ assert.ok(doc.body.includes(NEW) && !doc.body.includes(OLD), 'Search gets the corrected text');
+
+ // Nothing private leaks: not the request, not who sent it, not the editors' note.
+ const surfaces = [
+ page.text, r1.text, hist, (await req(h, 'GET', `/e/${e.slug}.json`)).text,
+ (await req(h, 'GET', `/api/v1/entities/${e.slug}`)).text, (await req(h, 'GET', `/api/v1/entities/${e.slug}/history`)).text,
+ (await req(h, 'GET', `/api/v1/entities/${e.slug}/summary/revisions/2`)).text,
+ (await req(h, 'GET', '/feed.json')).text, (await req(h, 'GET', '/feed.atom')).text, JSON.stringify(events),
+ ];
+ for (const s of surfaces) {
+ for (const secret of [REQUEST, INTERNAL, READER, c.id]) assert.ok(!s.includes(secret), `public surface carries ${secret}`);
+ }
+ // Editors still see the whole record.
+ const edHist = await req(h, 'GET', `/api/v1/entities/${e.slug}/history`, { token: editorToken() });
+ assert.ok(edHist.text.includes(c.id) && edHist.text.includes(INTERNAL));
+
+ // Closed is closed.
+ assert.strictEqual((await patch(editorToken(), accept)).status, 409);
+ } finally { await h.stop(); }
+});
+
+t('a rejected correction creates no revision; editors correct with plain forms, on their own or from the desk', async () => {
+ const h = await boot();
+ try {
+ const { e, pos, neg, summaryId } = await setup(h);
+ const ed = cookieFor(editorToken());
+ const c1 = await submit(h, e, 'I think this game deserves a higher score overall, honestly.');
+ const desk = noScripts((await req(h, 'GET', '/editor', { cookie: ed })).text);
+ assert.match(desk, /name="correction_note"/, 'the desk asks for the public note when a summary is published');
+ const rej = await req(h, 'POST', `/editor/corrections/${c1.id}`, { cookie: ed, form: { status: 'rejected', note: 'opinion, not an error', correction_note: 'ignored when rejecting' } });
+ assert.strictEqual(rej.status, 303);
+ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c1.id).status, 'rejected');
+ assert.strictEqual(revCount(h, summaryId), 1, 'rejecting creates no revision');
+ assert.ok(!outbox(h).some((x) => x.event_type === 'reviews.summary.updated'));
+
+ // From the desk: accept with a note, the published text carried forward as a correction revision.
+ const c2 = await submit(h, e, 'The signal table lists a review that its source has since edited.');
+ const short = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'fixed' } });
+ assert.strictEqual(short.status, 422, 'a correction note is a sentence readers can use');
+ assert.strictEqual(revCount(h, summaryId), 1);
+ const acc = await req(h, 'POST', `/editor/corrections/${c2.id}`, { cookie: ed, form: { status: 'accepted', correction_note: 'Checked the cited signals against their sources; the text stands.' } });
+ assert.strictEqual(acc.status, 303);
+ const r2 = h.db.prepare('SELECT content, meta FROM review_summary_revisions WHERE entity_id = ? AND number = 2').get(summaryId);
+ assert.strictEqual(r2.content, OLD, 'carried forward');
+ assert.strictEqual(JSON.parse(r2.meta).correction.note, 'Checked the cited signals against their sources; the text stands.');
+
+ // From the edit page, answering a request: the form names it and accepts it on publish.
+ const c3 = await submit(h, e, 'Only three of the four sampled reviewers recommend it, not all of them.');
+ const form = await req(h, 'GET', `/e/${e.slug}/edit?correction=${c3.id}`, { cookie: ed });
+ assert.ok(form.text.includes(`name="correction_id" value="${c3.id}"`));
+ assert.ok(noScripts(form.text).includes('Only three of the four sampled reviewers'), 'editors see the request');
+ const fixed = await req(h, 'POST', `/e/${e.slug}/summary`, { cookie: ed, form: {
+ overview: NEW, overview_signals: [...pos, ...neg], pro_text_0: 'Recommended by most of the sample', pro_signals_0: pos,
+ con_text_0: 'Not by everyone', con_signals_0: neg, correction_id: c3.id, correction_note: NOTE, expected_revision: '2', publish: '0',
+ } });
+ assert.strictEqual(fixed.status, 303, fixed.text.slice(0, 400));
+ assert.strictEqual(h.db.prepare('SELECT status FROM review_corrections WHERE id = ?').get(c3.id).status, 'accepted');
+ const s = h.db.prepare('SELECT * FROM review_summaries WHERE entity_id = ?').get(e.id);
+ assert.strictEqual(s.published_revision, 3, 'a correction is published at once');
+
+ // An editor corrects without any request.
+ const own = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
+ overview: `${NEW} The sample is recent reviews only.`, overview_signals: pos, correction_note: 'Added that the sample covers recent reviews only.',
+ } });
+ assert.strictEqual(own.status, 201, own.text);
+ assert.strictEqual(own.json.revision.number, 4);
+ assert.deepStrictEqual(own.json.revision.correction, { note: 'Added that the sample covers recent reviews only.', corrects: 3, requested: false });
+ const page = noScripts((await req(h, 'GET', `/e/${e.slug}`)).text);
+ for (const n of [NOTE, 'Added that the sample covers recent reviews only.', 'Checked the cited signals against their sources; the text stands.']) assert.ok(page.includes(n), n);
+ assert.match(page, /3 corrections\./);
+ assert.ok(!page.includes('opinion, not an error'));
+
+ // Nothing published → nothing to correct.
+ const other = await createEntity(h, { name: 'Unsummarised', kind: 'game' });
+ const none = await req(h, 'POST', `/api/v1/entities/${other.slug}/summary/revisions`, { token: editorToken(), body: { overview: 'x', overview_signals: pos, correction_note: 'This cannot be a correction.' } });
+ assert.strictEqual(none.status, 409);
+ assert.strictEqual(none.json.code, 'summary.not_published');
+ } finally { await h.stop(); }
+});
+
+t('a correction of approved AI text is AI-assisted, never "written by a person"', async () => {
+ const h = await boot();
+ try {
+ const e = await createEntity(h, { name: 'Portal 2', kind: 'game', aliases: [{ type: 'source', value: 'steam-reviews-portal-2' }] });
+ for (const v of [true, true, false]) await importItem(h, steamItem({ votedUp: v }));
+ const sigs = h.db.prepare("SELECT id FROM review_signals WHERE status = 'active'").all().map((s) => s.id);
+ const p = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/proposals`, { token: serviceToken({ client: 'ai', cap: ['reviews.summary.propose'] }), body: {
+ overview: 'All sampled reviewers recommend it.', overview_signals: sigs, workflow: { id: 'reviews.summarize_entity', run_id: 'run_01J9ZZZZZZZZZZZZZZZZZZZZZZ', version: 1 },
+ } });
+ assert.strictEqual(p.status, 201, p.text);
+ assert.strictEqual((await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions/1/review`, { token: editorToken(), body: { decision: 'approved' } })).status, 200);
+ const c = await req(h, 'POST', `/api/v1/entities/${e.slug}/summary/revisions`, { token: editorToken(), body: {
+ overview: 'Two of the three sampled reviewers recommend it.', overview_signals: sigs, correction_note: 'The AI draft said all reviewers recommend it; one does not.',
+ } });
+ assert.strictEqual(c.status, 201, c.text);
+ assert.strictEqual(c.json.revision.authorship.mode, 'hybrid');
+ const page = noScripts((await req(h, 'GET', `/e/${e.slug}`)).text);
+ assert.match(page, /Written by a person with AI assistance/);
+ assert.ok(page.includes('The AI draft said all reviewers recommend it; one does not.'));
+ } finally { await h.stop(); }
+});