This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

stigmergic / src / rdf-ingest.ts
12 kB 369 lines
1/** 2 * rdf:ingest 3 * 4 * Ingest the SciOS Resilient Data Futures discourse graph from 5 * https://github.com/jring-o/rdf into network.cosmik.connection records 6 * on the PDS. 7 * 8 * Each node becomes a vertex at https://rdf.scios.tech/node/{ID} 9 * Each edge from frontmatter becomes a network.cosmik.connection record. 10 * 11 * Edge type mapping: 12 * addresses → ADDRESSES (Claim → Question) 13 * supports → SUPPORTS (Evidence→Claim, Claim→Claim) 14 * opposes → OPPOSES (Claim→Claim, Evidence→Claim) 15 * derivedFrom → DERIVED_FROM (Evidence → Source) 16 * informs → INFORMS (Method → Claim) 17 * usesMethod → USES_METHOD (Claim → Method) 18 * 19 * Usage: 20 * bun rdf:ingest # dry run (prints plan) 21 * bun rdf:ingest --write # write to PDS 22 * bun rdf:ingest --write --json # write + JSON output 23 */ 24 25import { resolve, dirname, join } from 'node:path'; 26import { fileURLToPath } from 'node:url'; 27import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; 28import { AtpAgent } from '@atproto/api'; 29import { loadDotEnv } from './cli-utils.js'; 30 31const __dirname = dirname(fileURLToPath(import.meta.url)); 32await loadDotEnv(resolve(__dirname, '..', '.env')); 33await loadDotEnv('/home/nandi/code/swarm/.env'); 34 35const RDF_BASE = 'https://rdf.scios.tech/node/'; 36const PDS_IDENTIFIER = process.env.PDS_IDENTIFIER ?? process.env.BLUESKY_IDENTIFIER ?? ''; 37const PDS_APP_PASSWORD = process.env.PDS_APP_PASSWORD ?? process.env.BLUESKY_APP_PASSWORD ?? ''; 38const PDS_SERVICE = process.env.PDS_SERVICE ?? 'https://amanita.us-east.host.bsky.network'; 39const RDF_REPO = process.env.RDF_REPO ?? '/home/nandi/code/rdf'; 40const PROVENANCE_PATH = process.env.RDF_INGEST_PROVENANCE ?? resolve(RDF_REPO, '.rdf-ingest.json'); 41 42// --- Types --- 43 44interface NodeData { 45 id: string; 46 type: string; 47 title: string; 48 status: string; 49 sourceSection: string; 50 created: string; 51 edges: Record<string, string[]>; 52 body: string; 53 url: string; 54} 55 56interface EdgeToWrite { 57 source: string; // https://rdf.scios.tech/node/{from-id} 58 target: string; // https://rdf.scios.tech/node/{to-id} 59 connectionType: string; 60 note: string; 61 fromId: string; 62 toId: string; 63 edgeType: string; // original RDF edge type 64} 65 66const EDGE_TYPE_MAP: Record<string, string> = { 67 addresses: 'ADDRESSES', 68 supports: 'SUPPORTS', 69 opposes: 'OPPOSES', 70 derivedFrom: 'DERIVED_FROM', 71 informs: 'INFORMS', 72 usesMethod: 'USES_METHOD', 73}; 74 75const TYPE_DIRS: Record<string, string> = { 76 claim: 'claims', 77 evidence: 'evidence', 78 question: 'questions', 79 source: 'sources', 80 method: 'methods', 81}; 82 83// --- Parse --- 84 85function parseFrontmatter(content: string): { fm: Record<string, unknown>; body: string } { 86 const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); 87 if (!match) return { fm: {}, body: content }; 88 89 const fmText = match[1]; 90 const body = match[2].trim(); 91 92 // Two-pass YAML parser: 93 // Pass 1: extract top-level keys and nested dicts (like edges:) 94 // Pass 2: for each nested dict, parse sub-keys with inline arrays 95 96 const fm: Record<string, unknown> = {}; 97 const lines = fmText.split('\n'); 98 99 let i = 0; 100 while (i < lines.length) { 101 const line = lines[i]; 102 103 // Top-level key: value 104 const kvMatch = line.match(/^(\w+):\s*(.*)$/); 105 if (kvMatch) { 106 const key = kvMatch[1]; 107 const val = kvMatch[2].trim(); 108 109 if (val === '') { 110 // Could be a nested dict or empty — check next lines for indentation 111 const subDict: Record<string, string[]> = {}; 112 let j = i + 1; 113 while (j < lines.length && lines[j].match(/^\s{2,}/)) { 114 const subMatch = lines[j].match(/^\s+(\w+):\s*(.*)$/); 115 if (subMatch) { 116 const subKey = subMatch[1]; 117 const subVal = subMatch[2].trim(); 118 if (subVal.startsWith('[') && subVal.endsWith(']')) { 119 subDict[subKey] = subVal.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); 120 } else if (subVal === '' || subVal === '[]') { 121 subDict[subKey] = []; 122 } else { 123 subDict[subKey] = [subVal.replace(/^['"]|['"]$/g, '')]; 124 } 125 } 126 j++; 127 } 128 if (Object.keys(subDict).length > 0) { 129 fm[key] = subDict; 130 i = j; 131 continue; 132 } 133 // Empty dict 134 fm[key] = {}; 135 i++; 136 continue; 137 } 138 139 if (val.startsWith('[') && val.endsWith(']')) { 140 fm[key] = val.slice(1, -1).split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean); 141 } else if (val === '[]') { 142 fm[key] = []; 143 } else { 144 fm[key] = val.replace(/^['"]|['"]$/g, ''); 145 } 146 } 147 i++; 148 } 149 150 return { fm, body }; 151} 152 153function loadNodes(): NodeData[] { 154 const nodes: NodeData[] = []; 155 156 for (const [type, dir] of Object.entries(TYPE_DIRS)) { 157 const dirPath = join(RDF_REPO, 'graph', dir); 158 let files: string[]; 159 try { 160 files = readdirSync(dirPath).filter(f => f.endsWith('.md')).sort(); 161 } catch { 162 console.error(` Skipping ${dirPath}: not found`); 163 continue; 164 } 165 166 for (const file of files) { 167 const content = readFileSync(join(dirPath, file), 'utf-8'); 168 const { fm, body } = parseFrontmatter(content); 169 170 const id = (fm.id as string) ?? file.replace('.md', ''); 171 const edges: Record<string, string[]> = {}; 172 // fm.edges is the nested dict from YAML: { addresses: [Q-0001], supports: [...], ... } 173 const fmEdges = fm.edges as Record<string, string[] | unknown> | undefined; 174 if (fmEdges && typeof fmEdges === 'object') { 175 for (const [key, val] of Object.entries(fmEdges)) { 176 if (EDGE_TYPE_MAP[key]) { 177 edges[key] = Array.isArray(val) ? val : [val as string]; 178 } 179 } 180 } 181 // Also check top-level edge keys (flat frontmatter style) 182 for (const [key, val] of Object.entries(fm)) { 183 if (key === 'edges') continue; 184 if (EDGE_TYPE_MAP[key] && !edges[key]) { 185 edges[key] = Array.isArray(val) ? val : [val as string]; 186 } 187 } 188 189 nodes.push({ 190 id, 191 type: (fm.type as string) ?? type, 192 title: (fm.title as string) ?? id, 193 status: (fm.status as string) ?? 'draft', 194 sourceSection: (fm.source_section as string) ?? '', 195 created: (fm.created as string) ?? '', 196 edges, 197 body, 198 url: `${RDF_BASE}${id}`, 199 }); 200 } 201 } 202 203 return nodes; 204} 205 206function extractEdges(nodes: NodeData[]): EdgeToWrite[] { 207 const nodeMap = new Map(nodes.map(n => [n.id, n])); 208 const edges: EdgeToWrite[] = []; 209 const seen = new Set<string>(); 210 211 for (const node of nodes) { 212 for (const [edgeType, targets] of Object.entries(node.edges)) { 213 const connectionType = EDGE_TYPE_MAP[edgeType]; 214 if (!connectionType) continue; 215 216 for (const toId of targets) { 217 const key = `${node.id}:${edgeType}:${toId}`; 218 if (seen.has(key)) continue; 219 seen.add(key); 220 221 if (!nodeMap.has(toId)) { 222 console.error(` Warning: ${node.id} references missing node ${toId}`); 223 } 224 225 edges.push({ 226 source: node.url, 227 target: `${RDF_BASE}${toId}`, 228 connectionType, 229 note: `${node.id} --${edgeType}--> ${toId}`, 230 fromId: node.id, 231 toId, 232 edgeType, 233 }); 234 } 235 } 236 } 237 238 return edges; 239} 240 241// --- PDS writes --- 242 243async function writeEdges(edges: EdgeToWrite[]): Promise<{ wrote: number; uris: string[] }> { 244 if (!PDS_IDENTIFIER || !PDS_APP_PASSWORD) { 245 throw new Error('PDS_IDENTIFIER and PDS_APP_PASSWORD must be set'); 246 } 247 248 const agent = new AtpAgent({ service: PDS_SERVICE }); 249 await agent.login({ identifier: PDS_IDENTIFIER, password: PDS_APP_PASSWORD }); 250 const did = agent.session!.did; 251 252 let wrote = 0; 253 const uris: string[] = []; 254 255 // Batch in groups of 10 to avoid rate limits 256 for (let i = 0; i < edges.length; i += 10) { 257 const batch = edges.slice(i, i + 10); 258 const results = await Promise.allSettled( 259 batch.map(async (edge) => { 260 const record = { 261 $type: 'network.cosmik.connection', 262 source: edge.source, 263 target: edge.target, 264 connectionType: edge.connectionType, 265 note: edge.note, 266 createdAt: new Date().toISOString(), 267 }; 268 269 // Use createRecord to get auto-generated TID rkeys (putRecord with custom rkeys fails validation) 270 const res = await agent.api.com.atproto.repo.createRecord({ 271 repo: did, 272 collection: 'network.cosmik.connection', 273 record, 274 }); 275 return res.data.uri; 276 }), 277 ); 278 279 for (const result of results) { 280 if (result.status === 'fulfilled') { 281 wrote++; 282 uris.push(result.value); 283 } else { 284 console.error(` Failed: ${result.reason}`); 285 } 286 } 287 288 // Small delay between batches 289 if (i + 10 < edges.length) { 290 await new Promise(r => setTimeout(r, 500)); 291 } 292 } 293 294 return { wrote, uris }; 295} 296 297// --- Main --- 298 299async function main(): Promise<void> { 300 const args = process.argv.slice(2); 301 const wantWrite = args.includes('--write'); 302 const wantJson = args.includes('--json'); 303 304 console.error('Loading nodes from RDF repo...'); 305 const nodes = loadNodes(); 306 307 const typeCounts: Record<string, number> = {}; 308 for (const n of nodes) typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1; 309 console.error(` ${nodes.length} nodes: ${Object.entries(typeCounts).map(([t, c]) => `${t}: ${c}`).join(', ')}`); 310 311 console.error('Extracting edges...'); 312 const edges = extractEdges(nodes); 313 314 const edgeTypeCounts: Record<string, number> = {}; 315 for (const e of edges) edgeTypeCounts[e.edgeType] = (edgeTypeCounts[e.edgeType] ?? 0) + 1; 316 console.error(` ${edges.length} edges: ${Object.entries(edgeTypeCounts).map(([t, c]) => `${t}: ${c}`).join(', ')}`); 317 318 if (wantWrite) { 319 console.error(`Writing ${edges.length} edges to PDS...`); 320 const { wrote, uris } = await writeEdges(edges); 321 console.error(` Wrote ${wrote}/${edges.length} edges`); 322 323 saveJson(PROVENANCE_PATH, { 324 ingestedAt: new Date().toISOString(), 325 nodes: nodes.length, 326 edges: edges.length, 327 wrote, 328 uris: uris.slice(0, 100), 329 repo: 'https://github.com/jring-o/rdf', 330 }); 331 } 332 333 if (wantJson) { 334 console.log(JSON.stringify({ nodes, edges, wrote: wantWrite ? edges.length : 0 }, null, 2)); 335 return; 336 } 337 338 // Summary output 339 console.log(`\nRDF Discourse Graph: ${nodes.length} nodes, ${edges.length} edges`); 340 if (!wantWrite) { 341 console.log('(dry run — use --write to publish to PDS)'); 342 } 343 344 // Sample edges 345 console.log('\nEdge sample:'); 346 for (const e of edges.slice(0, 20)) { 347 console.log(` ${e.fromId} --${e.edgeType}--> ${e.toId} [${e.connectionType}]`); 348 } 349 if (edges.length > 20) { 350 console.log(` ... and ${edges.length - 20} more`); 351 } 352 353 // Node sample 354 console.log('\nNode sample:'); 355 for (const n of nodes.slice(0, 10)) { 356 const edgeCount = Object.values(n.edges).flat().length; 357 console.log(` ${n.id} [${n.type}] "${n.title.slice(0, 60)}" (${edgeCount} edges)`); 358 } 359} 360 361function saveJson(path: string, value: unknown): void { 362 mkdirSync(dirname(path), { recursive: true }); 363 writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf-8'); 364} 365 366main().catch((err) => { 367 console.error('Fatal:', err instanceof Error ? err.message : String(err)); 368 process.exit(1); 369});