openvibers/openvibe.news Commits
A story's label names each thing once (#205)
Changes
@@@ -99,13 +99,40 @@ function compare(sig, cluster, n = 1, common = null) {return { sharedEntities, sharedTerms, score, joins, coreMin: min };}++/**++ * A label names each thing once (11-03, the text lint on /live: "department · doj · doj to monitor elections" and++ * "ceconomy · ceconomy ag ceconomy"): in rank order, a name is left out when its words sit inside a name already++ * kept, or a kept name's words sit inside it, or it repeats a word of its own (a headline caught as an entity).++ */++function distinctNames(list, max) {++ const out = [];++ const words = (s) => String(s || '').split(/\s+/).filter(Boolean);++ const inside = (a, b) => ` ${b.join(' ')} `.includes(` ${a.join(' ')} `);++ for (const name of list) {++ const w = words(name);++ if (!w.length || new Set(w).size < w.length) continue;++ if (out.some((kept) => inside(w, words(kept)) || inside(words(kept), w))) continue;++ out.push(name);++ if (out.length >= max) break;++ }++ return out;++}++function labelOf(entities, terms) {const top = (obj) => Object.entries(obj).sort(([a, x], [b, y]) => (y - x) || (a < b ? -1 : 1)).map(([k]) => k);-- const e = top(entities).slice(0, 3);-- const words = e.length ? e : top(terms).slice(0, 4);++ const e = distinctNames(top(entities), 3);++ const words = e.length ? e : distinctNames(top(terms), 4);return words.length ? words.join(' · ') : 'unlabelled';}++/** A stored label as it is shown: the same rule, for labels written before it (they change only on a new report). */++function tidyLabel(label) {++ const parts = String(label || '').split(' · ');++ if (parts.length < 2) return label;++ const kept = distinctNames(parts, parts.length);++ return kept.length ? kept.join(' · ') : label;++}++function createClusters({ store, config, outbox }) {const { db } = store;const q = {@@@ -242,7 +269,7 @@ function createClusters({ store, config, outbox }) {}const api = {-- signature, compare, coreMin, commonWords, itemTime, labelOf, assign, resolve, recompute,++ signature, compare, coreMin, commonWords, itemTime, labelOf, tidyLabel, assign, resolve, recompute,get: async (id) => await q.get.get(String(id || '')) || null,members: async (id) => await q.members.all(id),@@@ -275,7 +302,7 @@ function createClusters({ store, config, outbox }) {const items = byCluster.get(c.id);if (!items || !items.length) continue;items.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));-- out.push({ id: c.id, label: c.label, updated: c.window_end, items });++ out.push({ id: c.id, label: tidyLabel(c.label), updated: c.window_end, items });if (out.length >= limit) break;}return out;@@@ -296,7 +323,7 @@ function createClusters({ store, config, outbox }) {const cutoff = store.now() + skewMs;for (const r of rows) {if (r.published_at && Date.parse(r.published_at) > cutoff) continue;-- if (!byCluster.has(r.cid)) byCluster.set(r.cid, { id: r.cid, label: r.label, updated: r.window_end, items: [], sources: new Set() });++ if (!byCluster.has(r.cid)) byCluster.set(r.cid, { id: r.cid, label: tidyLabel(r.label), updated: r.window_end, items: [], sources: new Set() });const c = byCluster.get(r.cid);c.sources.add(r.source_key);if (r.canonical_url) c.items.push({ id: r.id, headline: r.headline, url: r.canonical_url, outlet: r.outlet, published_at: r.published_at });@@@ -453,4 +480,4 @@ function createClusters({ store, config, outbox }) {return api;}--module.exports = { createClusters, signature, compare, coreMin, commonWords, itemTime, labelOf };++module.exports = { createClusters, signature, compare, coreMin, commonWords, itemTime, labelOf, tidyLabel, distinctNames };@@@ -111,6 +111,16 @@ const { boot, check, done, launchReports } = require('./helpers/boot');assert.deepStrictEqual(audit, ['merge', 'reverse_merge', 'split', 'reverse_split']);});++ await check('a label names each thing once (11-03: "department · doj · doj to monitor elections")', async () => {++ const { labelOf, tidyLabel } = require('../server/domain/clusters');++ assert.strictEqual(labelOf({ department: 9, doj: 8, 'doj to monitor elections': 7, justice: 3 }, {}), 'department · doj · justice', 'a name inside another is left out');++ assert.strictEqual(labelOf({ 'ceconomy ag ceconomy': 5, ceconomy: 4, mediamarkt: 2 }, {}), 'ceconomy · mediamarkt', 'a name that repeats a word is left out');++ assert.strictEqual(labelOf({}, { rail: 3, strike: 2, 'rail strike': 1 }), 'rail · strike', 'terms follow the same rule');++ assert.strictEqual(labelOf({ 'new york': 3, york: 2, york2: 1 }, {}), 'new york · york2', 'whole words only: york2 is not york');++ assert.strictEqual(tidyLabel('department · doj · doj to monitor elections'), 'department · doj', 'a stored label is shown tidy');++ assert.strictEqual(tidyLabel('unlabelled'), 'unlabelled');++ });++await check('a cluster is matched on its core, not the union of its reports: it cannot snowball', async () => {const { compare, coreMin, signature } = require('../server/domain/clusters');assert.deepStrictEqual([1, 2, 3, 5, 6, 196].map(coreMin), [1, 1, 2, 2, 3, 66]);
@@ -99,13 +99,40 @@ function compare(sig, cluster, n = 1, common = null) {
@@ -99,13 +99,40 @@ function compare(sig, cluster, n = 1, common = null) {
return { sharedEntities, sharedTerms, score, joins, coreMin: min };
return { sharedEntities, sharedTerms, score, joins, coreMin: min };
}
}
+/**
+ * A label names each thing once (11-03, the text lint on /live: "department · doj · doj to monitor elections" and
+ * "ceconomy · ceconomy ag ceconomy"): in rank order, a name is left out when its words sit inside a name already
+ * kept, or a kept name's words sit inside it, or it repeats a word of its own (a headline caught as an entity).
+ */
+function distinctNames(list, max) {
+ const out = [];
+ const words = (s) => String(s || '').split(/\s+/).filter(Boolean);
+ const inside = (a, b) => ` ${b.join(' ')} `.includes(` ${a.join(' ')} `);
+ for (const name of list) {
+ const w = words(name);
+ if (!w.length || new Set(w).size < w.length) continue;
+ if (out.some((kept) => inside(w, words(kept)) || inside(words(kept), w))) continue;
+ out.push(name);
+ if (out.length >= max) break;
+ }
+ return out;
+}
+
function labelOf(entities, terms) {
function labelOf(entities, terms) {
const top = (obj) => Object.entries(obj).sort(([a, x], [b, y]) => (y - x) || (a < b ? -1 : 1)).map(([k]) => k);
const top = (obj) => Object.entries(obj).sort(([a, x], [b, y]) => (y - x) || (a < b ? -1 : 1)).map(([k]) => k);
- const e = top(entities).slice(0, 3);
- const words = e.length ? e : top(terms).slice(0, 4);
+ const e = distinctNames(top(entities), 3);
+ const words = e.length ? e : distinctNames(top(terms), 4);
return words.length ? words.join(' · ') : 'unlabelled';
return words.length ? words.join(' · ') : 'unlabelled';
}
}
+/** A stored label as it is shown: the same rule, for labels written before it (they change only on a new report). */
+function tidyLabel(label) {
+ const parts = String(label || '').split(' · ');
+ if (parts.length < 2) return label;
+ const kept = distinctNames(parts, parts.length);
+ return kept.length ? kept.join(' · ') : label;
+}
+
function createClusters({ store, config, outbox }) {
function createClusters({ store, config, outbox }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
@@ -242,7 +269,7 @@ function createClusters({ store, config, outbox }) {
@@ -242,7 +269,7 @@ function createClusters({ store, config, outbox }) {
}
}
const api = {
const api = {
- signature, compare, coreMin, commonWords, itemTime, labelOf, assign, resolve, recompute,
+ signature, compare, coreMin, commonWords, itemTime, labelOf, tidyLabel, assign, resolve, recompute,
get: async (id) => await q.get.get(String(id || '')) || null,
get: async (id) => await q.get.get(String(id || '')) || null,
members: async (id) => await q.members.all(id),
members: async (id) => await q.members.all(id),
@@ -275,7 +302,7 @@ function createClusters({ store, config, outbox }) {
@@ -275,7 +302,7 @@ function createClusters({ store, config, outbox }) {
const items = byCluster.get(c.id);
const items = byCluster.get(c.id);
if (!items || !items.length) continue;
if (!items || !items.length) continue;
items.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));
items.sort((a, b) => String(b.published_at || '').localeCompare(String(a.published_at || '')));
- out.push({ id: c.id, label: c.label, updated: c.window_end, items });
+ out.push({ id: c.id, label: tidyLabel(c.label), updated: c.window_end, items });
if (out.length >= limit) break;
if (out.length >= limit) break;
}
}
return out;
return out;
@@ -296,7 +323,7 @@ function createClusters({ store, config, outbox }) {
@@ -296,7 +323,7 @@ function createClusters({ store, config, outbox }) {
const cutoff = store.now() + skewMs;
const cutoff = store.now() + skewMs;
for (const r of rows) {
for (const r of rows) {
if (r.published_at && Date.parse(r.published_at) > cutoff) continue;
if (r.published_at && Date.parse(r.published_at) > cutoff) continue;
- if (!byCluster.has(r.cid)) byCluster.set(r.cid, { id: r.cid, label: r.label, updated: r.window_end, items: [], sources: new Set() });
+ if (!byCluster.has(r.cid)) byCluster.set(r.cid, { id: r.cid, label: tidyLabel(r.label), updated: r.window_end, items: [], sources: new Set() });
const c = byCluster.get(r.cid);
const c = byCluster.get(r.cid);
c.sources.add(r.source_key);
c.sources.add(r.source_key);
if (r.canonical_url) c.items.push({ id: r.id, headline: r.headline, url: r.canonical_url, outlet: r.outlet, published_at: r.published_at });
if (r.canonical_url) c.items.push({ id: r.id, headline: r.headline, url: r.canonical_url, outlet: r.outlet, published_at: r.published_at });
@@ -453,4 +480,4 @@ function createClusters({ store, config, outbox }) {
@@ -453,4 +480,4 @@ function createClusters({ store, config, outbox }) {
return api;
return api;
}
}
-module.exports = { createClusters, signature, compare, coreMin, commonWords, itemTime, labelOf };
+module.exports = { createClusters, signature, compare, coreMin, commonWords, itemTime, labelOf, tidyLabel, distinctNames };
@@ -111,6 +111,16 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
@@ -111,6 +111,16 @@ const { boot, check, done, launchReports } = require('./helpers/boot');
assert.deepStrictEqual(audit, ['merge', 'reverse_merge', 'split', 'reverse_split']);
assert.deepStrictEqual(audit, ['merge', 'reverse_merge', 'split', 'reverse_split']);
});
});
+ await check('a label names each thing once (11-03: "department · doj · doj to monitor elections")', async () => {
+ const { labelOf, tidyLabel } = require('../server/domain/clusters');
+ assert.strictEqual(labelOf({ department: 9, doj: 8, 'doj to monitor elections': 7, justice: 3 }, {}), 'department · doj · justice', 'a name inside another is left out');
+ assert.strictEqual(labelOf({ 'ceconomy ag ceconomy': 5, ceconomy: 4, mediamarkt: 2 }, {}), 'ceconomy · mediamarkt', 'a name that repeats a word is left out');
+ assert.strictEqual(labelOf({}, { rail: 3, strike: 2, 'rail strike': 1 }), 'rail · strike', 'terms follow the same rule');
+ assert.strictEqual(labelOf({ 'new york': 3, york: 2, york2: 1 }, {}), 'new york · york2', 'whole words only: york2 is not york');
+ assert.strictEqual(tidyLabel('department · doj · doj to monitor elections'), 'department · doj', 'a stored label is shown tidy');
+ assert.strictEqual(tidyLabel('unlabelled'), 'unlabelled');
+ });
+
await check('a cluster is matched on its core, not the union of its reports: it cannot snowball', async () => {
await check('a cluster is matched on its core, not the union of its reports: it cannot snowball', async () => {
const { compare, coreMin, signature } = require('../server/domain/clusters');
const { compare, coreMin, signature } = require('../server/domain/clusters');
assert.deepStrictEqual([1, 2, 3, 5, 6, 196].map(coreMin), [1, 1, 2, 2, 3, 66]);
assert.deepStrictEqual([1, 2, 3, 5, 6, 196].map(coreMin), [1, 1, 2, 2, 3, 66]);