openvibers/openvibe.blog Commits
Blog desks: cited paragraphs, source numbers out of the text, the desk's title when the AI names the format (#58)
Changes
@@@ -34,6 +34,17 @@ const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '')const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);const newId = () => `dsk_${crypto.randomBytes(10).toString('hex')}`;++// Source numbers the AI was told not to write into its text: "(Source 3)", "(Sources 1, 2)", "[4]". The page links them.++const SOURCE_REFS = /\s*(?:\((?:sources?|items?)\s*[\d\s,&and-]+\)|\[\d+(?:\s*,\s*\d+)*\])/gi;++const prose = (v, n) => String(v == null ? '' : v).replace(SOURCE_REFS, '').replace(/[ \t]+/g, ' ').trim().slice(0, n);++const citesOf = (list, n) => [...new Set((Array.isArray(list) ? list : []).filter((x) => Number.isInteger(x) && x >= 0 && x < n))].sort((a, b) => a - b);++++/** The AI's headline, or the desk's own when the AI named the format ("… digest", "snapshot") instead of the news. */++function headline(title, fallback) {++ const t = clean(title, 200).replace(/\s*[([](?:[A-Z][a-z]{2,8}\.? \d{1,2},? \d{4}|\d{1,2} [A-Z][a-z]+ \d{4}|\d{4}-\d{2}-\d{2})[)\]]\s*$/, '');++ return t && !/\b(digest|desk|snapshots?|round-?ups?)\b/i.test(t) ? t : fallback;++}++/** The latest period of a desk that is over (and past its writing hour) at `now`: { from, to }. */function duePeriod(desk, now) {const d = new Date(now);@@@ -174,15 +185,20 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consolconst out = r.output || {};const gaps = (Array.isArray(out.gaps) ? out.gaps : []).map((g) => clean(g, 300)).filter(Boolean).slice(0, 20);const all = Array.isArray(out.sections) ? out.sections : [];-- const sections = all.map((s) => ({-- heading: clean(s && s.heading, 160),-- body: String((s && s.body) || '').trim().slice(0, 4000),-- cites: [...new Set((s && Array.isArray(s.citations) ? s.citations : []).filter((n) => Number.isInteger(n) && n >= 0 && n < items.length))].sort((a, b) => a - b),-- })).filter((s) => s.body && s.cites.length);-- if (sections.length < all.length) gaps.push(`${all.length - sections.length} section(s) the AI wrote without citing an item were left out.`);++ // Each paragraph keeps its own citations (an older answer's section body counts as one paragraph); a++ // paragraph that cites nothing is left out and the post says so.++ let dropped = 0;++ const sections = all.map((s) => {++ const paras = (s && Array.isArray(s.paragraphs) ? s.paragraphs : (s && s.body ? [{ text: s.body, citations: s.citations }] : []))++ .map((p) => ({ text: prose(p && p.text, 1500), cites: citesOf(p && p.citations, items.length) }));++ const kept = paras.filter((p) => p.text && p.cites.length);++ dropped += paras.length - kept.length;++ return { heading: clean(s && s.heading, 160), paragraphs: kept };++ }).filter((s) => s.paragraphs.length);++ if (dropped) gaps.push(`${dropped} paragraph(s) the AI wrote without citing an item were left out.`);if (!sections.length) throw new Error(`run ${r.runId} cited nothing, so no post was made`);const sources = items.map((it) => ({ item_id: it.item_id, source_key: it.source_key, title: it.title || null, url: it.url || null, published_at: it.published_at || null }));-- return await finish(row, r, { title: clean(out.title, 200) || `${desk.title}: ${label}`, dek: clean(out.dek, 400) || null, body: { sections, gaps }, sources });++ return await finish(row, r, { title: headline(out.title, `${desk.title}: ${label}`), dek: prose(out.dek, 400) || null, body: { sections, gaps }, sources });} catch (err) { await fail(row, err); return null; }}@@@ -294,4 +310,4 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol};}--module.exports = { createDesks, duePeriod, periodLabel, snippetOf, slugify };++module.exports = { createDesks, duePeriod, periodLabel, snippetOf, slugify, headline, prose };@@@ -61,10 +61,13 @@ ${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}<div class="post-body">${md(post.body.markdown, 0)}</div>${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.map((t) => h`<span>#${t}</span> `)}</p>` : ''}`);}-- const cite = (n) => h`<a href="#src-${n + 1}">${n + 1}</a>`;++ const cite = (n) => h`<a href="#src-${n + 1}" aria-label="Item ${n + 1}">${n + 1}</a>`;++ const cites = (list) => h` <span class="desk-cites">[${list.map((n, i) => h`${i ? ', ' : ''}${cite(n)}`)}]</span>`;++ // Rows written before the AI answered in paragraphs hold one body per section.++ const parasOf = (s) => (Array.isArray(s.paragraphs) ? s.paragraphs : [{ text: s.body, cites: s.cites || [], markdown: true }]);return String(h`<p class="disclosure" role="note"><strong>AI-generated</strong> from ${post.sources.length} items OpenVibe.Sources fetched, by OpenVibe.AI's blog.write_digest${post.model ? ` (${post.model})` : ''}; not reviewed by a person. Check anything that matters against the linked item.</p>${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}--<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${md(s.body, 2)}<p class="desk-cites">From ${s.cites.map((n, i) => h`${i ? ', ' : ''}${cite(n)}`)}</p></section>`)}</div>++<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${parasOf(s).map((p) => (p.markdown ? h`${md(p.text, 2)}<p>${cites(p.cites)}</p>` : h`<p>${p.text}${cites(p.cites)}</p>`))}</section>`)}</div>${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);}@@@ -80,11 +80,14 @@ function stub(handler) {const n = body.input.sources ? body.input.sources.length : 0;const output = body.workflow === 'blog.write_satire'? { title: 'Is it safe to let your air fryer vote?', dek: 'Experts at the Institute of Crisp are alarmed.', body_markdown: '## The crisis\n\nYour air fryer has opinions.\n\n# A stray title\n\nIt does.', tags: ['Appliances', 'democracy'] }-- : { title: 'Recalls and releases', dek: 'What was announced.', sections: [-- { heading: 'Announcements', body: 'Two agencies announced things.', citations: [0, 1, 99] },-- { heading: 'Unsourced', body: 'Something the AI made up.', citations: [] },-- ...(n > 2 ? [{ heading: 'Space', body: 'NASA said something.', citations: [2] }] : []),-- ], citations: [0], gaps: ['Item 3 had no date.'] };++ : body.input.desk === 'Hosting digest'++ // An answer in the first shape (one body per section) still makes a post.++ ? { title: 'Hosting digest for the week (5 October 2026)', dek: 'Plans.', sections: [{ heading: 'Plans', body: 'A plan changed its price.', citations: [0] }], citations: [0], gaps: [] }++ : { title: 'Recalls and releases', dek: 'What was announced.', sections: [++ { heading: 'Announcements', paragraphs: [{ text: 'Two agencies announced things (Source 0).', citations: [0, 1, 99] }, { text: 'An uncited aside.', citations: [] }] },++ { heading: 'Unsourced', paragraphs: [{ text: 'Something the AI made up.', citations: [] }] },++ ...(n > 2 ? [{ heading: 'Space', paragraphs: [{ text: 'NASA said something [2].', citations: [2] }] }] : []),++ ], citations: [0], gaps: ['Item 3 had no date.'] };return { status: 201, body: { run: { id, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, synthetic: mode === 'stub', provenance: { model: 'gpt-test' }, output } } };}if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };@@@ -114,8 +117,10 @@ function stub(handler) {assert.deepStrictEqual(byWorkflow('blog.write_satire').map((x) => x.input), [catalog.questionFor(0)]);news = await desks.bySlug('news', '2026-10-14');assert.strictEqual(news.status, 'published');-- assert.deepStrictEqual(news.body.sections.map((s) => [s.heading, s.cites]), [['Announcements', [0, 1]], ['Space', [2]]], 'uncited section dropped, out-of-range citation dropped');-- assert.ok(news.body.gaps.some((g) => g.includes('without citing')));++ assert.deepStrictEqual(news.body.sections.map((s) => [s.heading, s.paragraphs.map((p) => [p.text, p.cites])]),++ [['Announcements', [['Two agencies announced things.', [0, 1]]]], ['Space', [['NASA said something.', [2]]]]],++ 'uncited paragraphs and sections dropped, out-of-range citations dropped, source numbers taken out of the text');++ assert.ok(news.body.gaps.includes('2 paragraph(s) the AI wrote without citing an item were left out.'));satire = (await desks.list('satire')).posts[0];assert.strictEqual(satire.slug, '2026-10-14-is-it-safe-to-let-your-air-fryer-vote');assert.deepStrictEqual(satire.body.tags, ['appliances', 'democracy']);@@@ -138,6 +143,8 @@ function stub(handler) {const hosting = (await desks.list('hosting')).posts[0];assert.ok(hosting, 'the week with the price change gets a post');assert.strictEqual(hosting.slug, '2026-10-12');++ assert.strictEqual(hosting.title, 'Hosting digest: the week of 12 October to 18 October 2026', 'a title naming the format gives way to the desk\'s own');++ assert.deepStrictEqual(hosting.body.sections[0].paragraphs, [{ text: 'A plan changed its price.', cites: [0] }]);const req = runs.filter((x) => x.input.desk === 'Hosting digest').pop();assert.ok(req.input.sources[0].snippet.includes('Changed at the source (revision 2)') && req.input.sources[0].snippet.includes('price_monthly: 6'));});@@@ -172,7 +179,7 @@ function stub(handler) {assert.strictEqual(page.status, 200);assert.ok(page.text.includes('AI-generated</strong> from 3 items'));assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));-- assert.ok(page.text.includes('<a href="#src-1">1</a>') && page.text.includes('id="src-3"'));++ assert.ok(page.text.includes('<p>Two agencies announced things. <span class="desk-cites">[<a href="#src-1" aria-label="Item 1">1</a>, <a href="#src-2" aria-label="Item 2">2</a>]</span></p>') && page.text.includes('id="src-3"'));assert.ok(!page.text.includes('Something the AI made up'));assert.match(page.headers.get('cache-control'), /max-age=60/);const s = await t.get(`/desks/satire/${satire.slug}`);
@@ -34,6 +34,17 @@ const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '')
@@ -34,6 +34,17 @@ const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '')
const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
const newId = () => `dsk_${crypto.randomBytes(10).toString('hex')}`;
const newId = () => `dsk_${crypto.randomBytes(10).toString('hex')}`;
+// Source numbers the AI was told not to write into its text: "(Source 3)", "(Sources 1, 2)", "[4]". The page links them.
+const SOURCE_REFS = /\s*(?:\((?:sources?|items?)\s*[\d\s,&and-]+\)|\[\d+(?:\s*,\s*\d+)*\])/gi;
+const prose = (v, n) => String(v == null ? '' : v).replace(SOURCE_REFS, '').replace(/[ \t]+/g, ' ').trim().slice(0, n);
+const citesOf = (list, n) => [...new Set((Array.isArray(list) ? list : []).filter((x) => Number.isInteger(x) && x >= 0 && x < n))].sort((a, b) => a - b);
+
+/** The AI's headline, or the desk's own when the AI named the format ("… digest", "snapshot") instead of the news. */
+function headline(title, fallback) {
+ const t = clean(title, 200).replace(/\s*[([](?:[A-Z][a-z]{2,8}\.? \d{1,2},? \d{4}|\d{1,2} [A-Z][a-z]+ \d{4}|\d{4}-\d{2}-\d{2})[)\]]\s*$/, '');
+ return t && !/\b(digest|desk|snapshots?|round-?ups?)\b/i.test(t) ? t : fallback;
+}
+
/** The latest period of a desk that is over (and past its writing hour) at `now`: { from, to }. */
/** The latest period of a desk that is over (and past its writing hour) at `now`: { from, to }. */
function duePeriod(desk, now) {
function duePeriod(desk, now) {
const d = new Date(now);
const d = new Date(now);
@@ -174,15 +185,20 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -174,15 +185,20 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
const out = r.output || {};
const out = r.output || {};
const gaps = (Array.isArray(out.gaps) ? out.gaps : []).map((g) => clean(g, 300)).filter(Boolean).slice(0, 20);
const gaps = (Array.isArray(out.gaps) ? out.gaps : []).map((g) => clean(g, 300)).filter(Boolean).slice(0, 20);
const all = Array.isArray(out.sections) ? out.sections : [];
const all = Array.isArray(out.sections) ? out.sections : [];
- const sections = all.map((s) => ({
- heading: clean(s && s.heading, 160),
- body: String((s && s.body) || '').trim().slice(0, 4000),
- cites: [...new Set((s && Array.isArray(s.citations) ? s.citations : []).filter((n) => Number.isInteger(n) && n >= 0 && n < items.length))].sort((a, b) => a - b),
- })).filter((s) => s.body && s.cites.length);
- if (sections.length < all.length) gaps.push(`${all.length - sections.length} section(s) the AI wrote without citing an item were left out.`);
+ // Each paragraph keeps its own citations (an older answer's section body counts as one paragraph); a
+ // paragraph that cites nothing is left out and the post says so.
+ let dropped = 0;
+ const sections = all.map((s) => {
+ const paras = (s && Array.isArray(s.paragraphs) ? s.paragraphs : (s && s.body ? [{ text: s.body, citations: s.citations }] : []))
+ .map((p) => ({ text: prose(p && p.text, 1500), cites: citesOf(p && p.citations, items.length) }));
+ const kept = paras.filter((p) => p.text && p.cites.length);
+ dropped += paras.length - kept.length;
+ return { heading: clean(s && s.heading, 160), paragraphs: kept };
+ }).filter((s) => s.paragraphs.length);
+ if (dropped) gaps.push(`${dropped} paragraph(s) the AI wrote without citing an item were left out.`);
if (!sections.length) throw new Error(`run ${r.runId} cited nothing, so no post was made`);
if (!sections.length) throw new Error(`run ${r.runId} cited nothing, so no post was made`);
const sources = items.map((it) => ({ item_id: it.item_id, source_key: it.source_key, title: it.title || null, url: it.url || null, published_at: it.published_at || null }));
const sources = items.map((it) => ({ item_id: it.item_id, source_key: it.source_key, title: it.title || null, url: it.url || null, published_at: it.published_at || null }));
- return await finish(row, r, { title: clean(out.title, 200) || `${desk.title}: ${label}`, dek: clean(out.dek, 400) || null, body: { sections, gaps }, sources });
+ return await finish(row, r, { title: headline(out.title, `${desk.title}: ${label}`), dek: prose(out.dek, 400) || null, body: { sections, gaps }, sources });
} catch (err) { await fail(row, err); return null; }
} catch (err) { await fail(row, err); return null; }
}
}
@@ -294,4 +310,4 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
@@ -294,4 +310,4 @@ function createDesks({ config, store, fetchImpl = globalThis.fetch, log = consol
};
};
}
}
-module.exports = { createDesks, duePeriod, periodLabel, snippetOf, slugify };
+module.exports = { createDesks, duePeriod, periodLabel, snippetOf, slugify, headline, prose };
@@ -61,10 +61,13 @@ ${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
@@ -61,10 +61,13 @@ ${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
<div class="post-body">${md(post.body.markdown, 0)}</div>
<div class="post-body">${md(post.body.markdown, 0)}</div>
${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.map((t) => h`<span>#${t}</span> `)}</p>` : ''}`);
${post.body.tags && post.body.tags.length ? h`<p class="tags">${post.body.tags.map((t) => h`<span>#${t}</span> `)}</p>` : ''}`);
}
}
- const cite = (n) => h`<a href="#src-${n + 1}">${n + 1}</a>`;
+ const cite = (n) => h`<a href="#src-${n + 1}" aria-label="Item ${n + 1}">${n + 1}</a>`;
+ const cites = (list) => h` <span class="desk-cites">[${list.map((n, i) => h`${i ? ', ' : ''}${cite(n)}`)}]</span>`;
+ // Rows written before the AI answered in paragraphs hold one body per section.
+ const parasOf = (s) => (Array.isArray(s.paragraphs) ? s.paragraphs : [{ text: s.body, cites: s.cites || [], markdown: true }]);
return String(h`<p class="disclosure" role="note"><strong>AI-generated</strong> from ${post.sources.length} items OpenVibe.Sources fetched, by OpenVibe.AI's blog.write_digest${post.model ? ` (${post.model})` : ''}; not reviewed by a person. Check anything that matters against the linked item.</p>
return String(h`<p class="disclosure" role="note"><strong>AI-generated</strong> from ${post.sources.length} items OpenVibe.Sources fetched, by OpenVibe.AI's blog.write_digest${post.model ? ` (${post.model})` : ''}; not reviewed by a person. Check anything that matters against the linked item.</p>
${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
${post.dek ? h`<p class="lede">${post.dek}</p>` : ''}
-<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${md(s.body, 2)}<p class="desk-cites">From ${s.cites.map((n, i) => h`${i ? ', ' : ''}${cite(n)}`)}</p></section>`)}</div>
+<div class="post-body">${post.body.sections.map((s) => h`<section class="desk-section">${s.heading ? h`<h2>${s.heading}</h2>` : ''}${parasOf(s).map((p) => (p.markdown ? h`${md(p.text, 2)}<p>${cites(p.cites)}</p>` : h`<p>${p.text}${cites(p.cites)}</p>`))}</section>`)}</div>
${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}
${post.body.gaps && post.body.gaps.length ? h`<details class="desk-gaps"><summary>What the AI could not tell from the items</summary><ul>${post.body.gaps.map((g) => h`<li>${g}</li>`)}</ul></details>` : ''}
<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);
<section class="sources"><h2>The items it was written from</h2><ol>${post.sources.map((s, i) => h`<li id="src-${i + 1}">${s.url ? h`<a href="${s.url}" rel="nofollow noopener">${s.title || s.url}</a>` : (s.title || s.item_id)} <span class="meta">(${s.source_key}${s.published_at ? h`, ${String(s.published_at).slice(0, 10)}` : ''})</span></li>`)}</ol></section>`);
}
}
@@ -80,11 +80,14 @@ function stub(handler) {
@@ -80,11 +80,14 @@ function stub(handler) {
const n = body.input.sources ? body.input.sources.length : 0;
const n = body.input.sources ? body.input.sources.length : 0;
const output = body.workflow === 'blog.write_satire'
const output = body.workflow === 'blog.write_satire'
? { title: 'Is it safe to let your air fryer vote?', dek: 'Experts at the Institute of Crisp are alarmed.', body_markdown: '## The crisis\n\nYour air fryer has opinions.\n\n# A stray title\n\nIt does.', tags: ['Appliances', 'democracy'] }
? { title: 'Is it safe to let your air fryer vote?', dek: 'Experts at the Institute of Crisp are alarmed.', body_markdown: '## The crisis\n\nYour air fryer has opinions.\n\n# A stray title\n\nIt does.', tags: ['Appliances', 'democracy'] }
- : { title: 'Recalls and releases', dek: 'What was announced.', sections: [
- { heading: 'Announcements', body: 'Two agencies announced things.', citations: [0, 1, 99] },
- { heading: 'Unsourced', body: 'Something the AI made up.', citations: [] },
- ...(n > 2 ? [{ heading: 'Space', body: 'NASA said something.', citations: [2] }] : []),
- ], citations: [0], gaps: ['Item 3 had no date.'] };
+ : body.input.desk === 'Hosting digest'
+ // An answer in the first shape (one body per section) still makes a post.
+ ? { title: 'Hosting digest for the week (5 October 2026)', dek: 'Plans.', sections: [{ heading: 'Plans', body: 'A plan changed its price.', citations: [0] }], citations: [0], gaps: [] }
+ : { title: 'Recalls and releases', dek: 'What was announced.', sections: [
+ { heading: 'Announcements', paragraphs: [{ text: 'Two agencies announced things (Source 0).', citations: [0, 1, 99] }, { text: 'An uncited aside.', citations: [] }] },
+ { heading: 'Unsourced', paragraphs: [{ text: 'Something the AI made up.', citations: [] }] },
+ ...(n > 2 ? [{ heading: 'Space', paragraphs: [{ text: 'NASA said something [2].', citations: [2] }] }] : []),
+ ], citations: [0], gaps: ['Item 3 had no date.'] };
return { status: 201, body: { run: { id, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, synthetic: mode === 'stub', provenance: { model: 'gpt-test' }, output } } };
return { status: 201, body: { run: { id, status: 'succeeded', workflow: { key: body.workflow, version: 1 }, synthetic: mode === 'stub', provenance: { model: 'gpt-test' }, output } } };
}
}
if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };
if (/^\/api\/v1\/runs\/[^/]+\/citations$/.test(req.url)) return { body: { citations: [] } };
@@ -114,8 +117,10 @@ function stub(handler) {
@@ -114,8 +117,10 @@ function stub(handler) {
assert.deepStrictEqual(byWorkflow('blog.write_satire').map((x) => x.input), [catalog.questionFor(0)]);
assert.deepStrictEqual(byWorkflow('blog.write_satire').map((x) => x.input), [catalog.questionFor(0)]);
news = await desks.bySlug('news', '2026-10-14');
news = await desks.bySlug('news', '2026-10-14');
assert.strictEqual(news.status, 'published');
assert.strictEqual(news.status, 'published');
- assert.deepStrictEqual(news.body.sections.map((s) => [s.heading, s.cites]), [['Announcements', [0, 1]], ['Space', [2]]], 'uncited section dropped, out-of-range citation dropped');
- assert.ok(news.body.gaps.some((g) => g.includes('without citing')));
+ assert.deepStrictEqual(news.body.sections.map((s) => [s.heading, s.paragraphs.map((p) => [p.text, p.cites])]),
+ [['Announcements', [['Two agencies announced things.', [0, 1]]]], ['Space', [['NASA said something.', [2]]]]],
+ 'uncited paragraphs and sections dropped, out-of-range citations dropped, source numbers taken out of the text');
+ assert.ok(news.body.gaps.includes('2 paragraph(s) the AI wrote without citing an item were left out.'));
satire = (await desks.list('satire')).posts[0];
satire = (await desks.list('satire')).posts[0];
assert.strictEqual(satire.slug, '2026-10-14-is-it-safe-to-let-your-air-fryer-vote');
assert.strictEqual(satire.slug, '2026-10-14-is-it-safe-to-let-your-air-fryer-vote');
assert.deepStrictEqual(satire.body.tags, ['appliances', 'democracy']);
assert.deepStrictEqual(satire.body.tags, ['appliances', 'democracy']);
@@ -138,6 +143,8 @@ function stub(handler) {
@@ -138,6 +143,8 @@ function stub(handler) {
const hosting = (await desks.list('hosting')).posts[0];
const hosting = (await desks.list('hosting')).posts[0];
assert.ok(hosting, 'the week with the price change gets a post');
assert.ok(hosting, 'the week with the price change gets a post');
assert.strictEqual(hosting.slug, '2026-10-12');
assert.strictEqual(hosting.slug, '2026-10-12');
+ assert.strictEqual(hosting.title, 'Hosting digest: the week of 12 October to 18 October 2026', 'a title naming the format gives way to the desk\'s own');
+ assert.deepStrictEqual(hosting.body.sections[0].paragraphs, [{ text: 'A plan changed its price.', cites: [0] }]);
const req = runs.filter((x) => x.input.desk === 'Hosting digest').pop();
const req = runs.filter((x) => x.input.desk === 'Hosting digest').pop();
assert.ok(req.input.sources[0].snippet.includes('Changed at the source (revision 2)') && req.input.sources[0].snippet.includes('price_monthly: 6'));
assert.ok(req.input.sources[0].snippet.includes('Changed at the source (revision 2)') && req.input.sources[0].snippet.includes('price_monthly: 6'));
});
});
@@ -172,7 +179,7 @@ function stub(handler) {
@@ -172,7 +179,7 @@ function stub(handler) {
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('AI-generated</strong> from 3 items'));
assert.ok(page.text.includes('AI-generated</strong> from 3 items'));
assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));
assert.ok(page.text.includes('href="https://example.gov/n2" rel="nofollow noopener"'));
- assert.ok(page.text.includes('<a href="#src-1">1</a>') && page.text.includes('id="src-3"'));
+ assert.ok(page.text.includes('<p>Two agencies announced things. <span class="desk-cites">[<a href="#src-1" aria-label="Item 1">1</a>, <a href="#src-2" aria-label="Item 2">2</a>]</span></p>') && page.text.includes('id="src-3"'));
assert.ok(!page.text.includes('Something the AI made up'));
assert.ok(!page.text.includes('Something the AI made up'));
assert.match(page.headers.get('cache-control'), /max-age=60/);
assert.match(page.headers.get('cache-control'), /max-age=60/);
const s = await t.get(`/desks/satire/${satire.slug}`);
const s = await t.get(`/desks/satire/${satire.slug}`);