const pull = require('../server/node_modules/pull-stream'); const { getConfig } = require('../configs/config-manager.js'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const logLimit = getConfig().ssbLogStream?.limit || 1000; const norm = (v) => String(v == null ? '' : v).trim().toLowerCase(); const KINDS = { inhabitants: { type: 'curriculum', href: (id, c) => `/author/${encodeURIComponent(c.author)}` }, jobs: { type: 'job', href: (id) => `/jobs/${encodeURIComponent(id)}` }, projects: { type: 'project', href: (id) => `/projects/${encodeURIComponent(id)}` }, events: { type: 'event', href: (id) => `/events/${encodeURIComponent(id)}` }, tribes: { type: 'tribe', href: (id) => `/tribe/${encodeURIComponent(id)}` }, market: { type: 'market', href: (id) => `/market/${encodeURIComponent(id)}` }, housing: { type: 'housing', href: (id) => `/housing/${encodeURIComponent(id)}` }, industry: { type: 'industry', href: (id) => `/industry/${encodeURIComponent(id)}` }, tasks: { type: 'task', href: (id) => `/tasks/${encodeURIComponent(id)}` }, reports: { type: 'report', href: (id) => `/reports/${encodeURIComponent(id)}` }, votes: { type: 'poll', href: (id) => `/polls/${encodeURIComponent(id)}` }, audios: { type: 'audio', href: (id) => `/audios/${encodeURIComponent(id)}` }, videos: { type: 'video', href: (id) => `/videos/${encodeURIComponent(id)}` }, images: { type: 'image', href: (id) => `/images/${encodeURIComponent(id)}` }, documents: { type: 'document', href: (id) => `/documents/${encodeURIComponent(id)}` }, bookmarks: { type: 'bookmark', href: (id) => `/bookmarks/${encodeURIComponent(id)}` }, torrents: { type: 'torrent', href: (id) => `/torrents/${encodeURIComponent(id)}` }, chats: { type: 'chat', href: (id) => `/chats/${encodeURIComponent(id)}` }, pads: { type: 'pad', href: (id) => `/pads/${encodeURIComponent(id)}` }, maps: { type: 'map', href: (id) => `/maps/${encodeURIComponent(id)}` }, calendars: { type: 'calendar', href: (id) => `/calendars/${encodeURIComponent(id)}` }, forum: { type: 'forum', href: (id) => `/forum/${encodeURIComponent(id)}` } }; const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k])); const cvSkills = (c) => [ ...(c.personalSkills || []), ...(c.oasisSkills || []), ...(c.educationalSkills || []), ...(c.professionalSkills || []) ]; const PLACEHOLDERS = new Set(['unknown', 'n/a', 'na', 'none', '-', 'other']); const TITLE_STOPWORDS = new Set([ 'the', 'this', 'that', 'and', 'for', 'with', 'from', 'into', 'about', 'new', 'all', 'not', 'are', 'was', 'you', 'your', 'our', 'los', 'las', 'del', 'una', 'unos', 'unas', 'este', 'esta', 'esto', 'que', 'con', 'para', 'por', 'sin', 'sobre', 'bajo', 'mas', 'más' ]); const titleTerms = (c) => String(c.title || c.name || c.question || c.concept || '') .toLowerCase() .split(/[^0-9A-Za-zÀ-￿]+/) // sin \p{}: nodejs-mobile no lleva ICU completo .filter(w => w.length >= 3 && !TITLE_STOPWORDS.has(w) && !/^\d+$/.test(w)); const termsOf = (kind, c) => { const out = []; if (kind === 'inhabitants') { out.push(...cvSkills(c)); if (c.languages) out.push(...String(c.languages).split(/[,;]/)); } else if (kind === 'jobs') { out.push(...(c.tasks || []), ...(c.tags || []), c.job_type, c.location); } else if (kind === 'industry') { out.push(...(c.tags || []), c.sector, ...(c.skills || [])); } else { out.push(...(c.tags || [])); if (c.category) out.push(c.category); } return Array.from(new Set(out.map(norm).filter(t => t && !PLACEHOLDERS.has(t)))); }; const titleOf = (kind, c, author) => { if (kind === 'inhabitants') return c.name || author || ''; return c.title || c.name || c.question || c.concept || ''; }; const jaccard = (a, b) => { if (!a.size || !b.size) return { score: 0, common: [] }; const common = [...b].filter(x => a.has(x)); const union = a.size + b.size - common.length; return { score: union > 0 ? common.length / union : 0, common }; }; module.exports = ({ cooler }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const getAllMessages = async (ssbClient) => new Promise((resolve, reject) => { pull( ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))) ); }); const buildGraph = async () => { const ssbClient = await openSsb(); const viewerId = ssbClient.id; const messages = await getAllMessages(ssbClient); const tomb = buildValidatedTombstoneSet(messages); const latestByKey = new Map(); const replaced = new Set(); for (const m of messages) { const v = m && m.value; const c = v && v.content; if (!c || typeof c !== 'object' || !c.type) continue; if (tomb.has(m.key)) continue; if (c.encryptedPayload || c.encryptedQuestion) continue; if (c.tribeId && c.type !== 'tribe') continue; const kind = KIND_BY_TYPE[c.type]; if (!kind) continue; if (typeof c.replaces === 'string') replaced.add(c.replaces); latestByKey.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, kind, c }); } const nodes = []; const byAuthorCv = new Map(); for (const node of latestByKey.values()) { if (replaced.has(node.key)) continue; const coreTerms = termsOf(node.kind, node.c); const extra = node.kind === 'inhabitants' ? [] : titleTerms(node.c).map(norm).filter(t => t && !PLACEHOLDERS.has(t)); const terms = Array.from(new Set([...coreTerms, ...extra])); if (!terms.length) continue; const entry = { id: node.key, kind: node.kind, author: node.c.author || node.author, title: titleOf(node.kind, node.c, node.c.author || node.author), terms, termSet: new Set(terms), coreTermSet: new Set(coreTerms), ts: node.ts, createdAt: node.c.createdAt || new Date(node.ts).toISOString(), href: KINDS[node.kind].href(node.key, node.c) }; if (node.kind === 'inhabitants') { const prev = byAuthorCv.get(entry.author); if (prev && prev.ts >= entry.ts) continue; byAuthorCv.set(entry.author, entry); continue; } nodes.push(entry); } for (const cv of byAuthorCv.values()) nodes.push(cv); return { viewerId, nodes, cvByAuthor: byAuthorCv }; }; const MAX_ENTITIES = 400; const MAX_PAIRS = 300; const strip = (n) => ({ id: n.id, kind: n.kind, author: n.author, title: n.title, href: n.href, createdAt: n.createdAt, ts: n.ts }); return { KINDS: Object.keys(KINDS), async listMatches(filter = 'ALL', opts = {}) { const { viewerId, nodes, cvByAuthor } = await buildGraph(); const use = nodes.slice(0, MAX_ENTITIES); const f = String(filter || 'ALL').toUpperCase(); const myTermSet = new Set(); const mineCv = cvByAuthor.get(viewerId); if (mineCv) for (const t of mineCv.terms) myTermSet.add(t); for (const n of use) { if (String(n.author) === String(viewerId)) for (const t of n.terms) myTermSet.add(t); } const df = new Map(); for (const n of use) for (const t of n.termSet) df.set(t, (df.get(t) || 0) + 1); const total = use.length || 1; const weightOf = (t) => Math.log(1 + total / (df.get(t) || 1)); let out = []; for (const n of use) { if (String(n.author) === String(viewerId)) continue; const common = [...n.termSet].filter(t => myTermSet.has(t)); if (!common.length) continue; let commonW = 0; for (const t of common) commonW += weightOf(t); let itemW = 0; for (const t of n.termSet) itemW += weightOf(t); const score = itemW > 0 ? Math.min(1, commonW / itemW) : 0; if (score <= 0) continue; common.sort((x, y) => weightOf(y) - weightOf(x) || x.localeCompare(y)); out.push({ ...strip(n), score, common, connections: common.length }); } if (f === 'RECENT') { const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; out = out.filter(s => s.ts >= cutoff); } else if (f !== 'ALL' && f !== 'TOP') { const kind = f.toLowerCase(); if (KINDS[kind]) out = out.filter(s => s.kind === kind); } const q = norm(opts.q); if (q) out = out.filter(s => norm(s.title).includes(q) || s.common.some(t => t.includes(q))); if (f === 'RECENT') out.sort((x, y) => y.ts - x.ts || y.score - x.score); else out.sort((x, y) => y.score - x.score || y.ts - x.ts); return { matches: out.slice(0, MAX_PAIRS), total: out.length, hasProfile: myTermSet.size > 0, myTerms: [...myTermSet] }; }, async jobMatchesFor(viewerId, { minScore = 0.8 } = {}) { const { nodes, cvByAuthor } = await buildGraph(); const mine = cvByAuthor.get(viewerId); if (!mine) return []; return nodes .filter(n => n.kind === 'jobs' && String(n.author) !== String(viewerId)) .map(n => { const { score, common } = jaccard(mine.termSet, n.coreTermSet || n.termSet); return { id: n.id, title: n.title, author: n.author, href: n.href, score, common }; }) .filter(m => m.score >= minScore) .sort((a, b) => b.score - a.score); }, async cohesion() { const { cvByAuthor, nodes } = await buildGraph(); const use = nodes.slice(0, MAX_ENTITIES); let comparisons = 0; let sum = 0; let connectedPairs = 0; const linkedEntities = new Set(); for (let i = 0; i < use.length; i++) { for (let j = i + 1; j < use.length; j++) { const a = use[i], b = use[j]; if (a.kind === 'inhabitants' && b.kind === 'inhabitants' && a.author === b.author) continue; const { score } = jaccard(a.termSet, b.termSet); comparisons += 1; sum += score; if (score > 0) { connectedPairs += 1; linkedEntities.add(a.id); linkedEntities.add(b.id); } } } const coefficient = comparisons > 0 ? sum / comparisons : 0; const people = [...cvByAuthor.values()]; const linked = new Set(); let cvComparisons = 0; let cvSum = 0; for (let i = 0; i < people.length; i++) { for (let j = i + 1; j < people.length; j++) { const { score } = jaccard(people[i].termSet, people[j].termSet); cvComparisons += 1; cvSum += score; if (score > 0) { linked.add(people[i].author); linked.add(people[j].author); } } } const cvCoefficient = cvComparisons > 0 ? cvSum / cvComparisons : 0; const skillSet = new Set(); for (const p of people) for (const t of p.terms) skillSet.add(t); const termCount = new Map(); for (const n of nodes) for (const t of n.terms) termCount.set(t, (termCount.get(t) || 0) + 1); const topTerms = [...termCount.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 12) .map(([term, count]) => ({ term, count })); const perKind = {}; for (const n of nodes) perKind[n.kind] = (perKind[n.kind] || 0) + 1; return { coefficient, percent: Math.round(coefficient * 1000) / 10, comparisons, pairs: connectedPairs, entities: use.length, distinctTerms: termCount.size, topTerms, perKind, people: people.length, skills: skillSet.size, connected: linkedEntities.size, isolated: Math.max(0, use.length - linkedEntities.size), cvCoefficient, cvPercent: Math.round(cvCoefficient * 1000) / 10 }; } }; };