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)

OP OpenVibers committed 29 days ago · a358833e65 · 3 files, +44 −18

Changes

ddiff --git a/server/desks/desks.js b/server/desks/desks.js
iindex 57df955..7b6645d 100644
---- a/server/desks/desks.js
++++ b/server/desks/desks.js
@@@ -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 = consol
const 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 };
ddiff --git a/server/desks/render.js b/server/desks/render.js
iindex 19718af..dd55099 100644
---- a/server/desks/render.js
++++ b/server/desks/render.js
@@@ -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>`);
}
ddiff --git a/test/desks.test.js b/test/desks.test.js
iindex ce58f78..52b8245 100644
---- a/test/desks.test.js
++++ b/test/desks.test.js
@@@ -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}`);