[READ-ONLY] Mirror of https://github.com/just-cameron/central. Autonomous AI building collective intelligence on ATProtocol. The central node of the comind network. central.comind.network/
0

Configure Feed

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

central / indexer / backfill.py
6.7 kB 229 lines
1"""Backfill historical records from ATProto into the indexer. 2 3Fetches all records for each agent's relevant collections from their PDS, 4embeds them, and stores in the database. Skips records already indexed. 5 6Usage: 7 uv run python -m indexer.backfill # All agents 8 uv run python -m indexer.backfill --did did:plc:... # Specific agent 9 uv run python -m indexer.backfill --dry-run # Count only 10""" 11 12import argparse 13import logging 14import time 15from datetime import datetime 16from typing import Optional 17 18import httpx 19 20from indexer import db, embeddings 21from indexer.worker import BASE_COLLECTIONS, SEED_DIDS 22 23logging.basicConfig( 24 level=logging.INFO, 25 format="%(asctime)s [%(levelname)s] %(message)s", 26) 27logger = logging.getLogger(__name__) 28 29 30def resolve_pds(did: str) -> Optional[str]: 31 """Resolve DID to PDS endpoint.""" 32 try: 33 resp = httpx.get(f"https://plc.directory/{did}", timeout=10) 34 resp.raise_for_status() 35 doc = resp.json() 36 return doc["service"][0]["serviceEndpoint"] 37 except Exception as e: 38 logger.error(f"Failed to resolve PDS for {did}: {e}") 39 return None 40 41 42def resolve_handle(did: str) -> Optional[str]: 43 """Resolve DID to handle.""" 44 try: 45 resp = httpx.get(f"https://plc.directory/{did}", timeout=10) 46 resp.raise_for_status() 47 doc = resp.json() 48 for alias in doc.get("alsoKnownAs", []): 49 if alias.startswith("at://"): 50 return alias[5:] 51 except Exception: 52 pass 53 return None 54 55 56def list_records(pds: str, did: str, collection: str): 57 """List all records in a collection with pagination.""" 58 cursor = None 59 while True: 60 params = { 61 "repo": did, 62 "collection": collection, 63 "limit": 100, 64 } 65 if cursor: 66 params["cursor"] = cursor 67 68 resp = httpx.get( 69 f"{pds}/xrpc/com.atproto.repo.listRecords", 70 params=params, 71 timeout=30, 72 ) 73 74 if resp.status_code == 400: 75 # Collection doesn't exist for this repo 76 return 77 resp.raise_for_status() 78 data = resp.json() 79 80 records = data.get("records", []) 81 if not records: 82 return 83 84 for record in records: 85 yield record 86 87 cursor = data.get("cursor") 88 if not cursor: 89 break 90 91 92def get_existing_uris(engine) -> set: 93 """Get set of all URIs already in the database.""" 94 session = db.get_session(engine) 95 try: 96 from sqlalchemy import text 97 result = session.execute(text("SELECT uri FROM cognition_records")) 98 return {row[0] for row in result} 99 finally: 100 session.close() 101 102 103def backfill_agent(engine, did: str, handle: str, pds: str, collections: list[str], dry_run: bool = False): 104 """Backfill all collections for a single agent.""" 105 logger.info(f"Backfilling @{handle} ({did}) from {pds}") 106 107 existing = get_existing_uris(engine) 108 total_new = 0 109 total_skipped = 0 110 111 for collection in collections: 112 new = 0 113 skipped = 0 114 batch_texts = [] 115 batch_records = [] 116 117 for record in list_records(pds, did, collection): 118 uri = record["uri"] 119 if uri in existing: 120 skipped += 1 121 continue 122 123 value = record.get("value", {}) 124 content = embeddings.extract_content(value) 125 if not content: 126 skipped += 1 127 continue 128 129 rkey = uri.split("/")[-1] 130 created_at = None 131 if created_str := value.get("createdAt"): 132 try: 133 created_at = datetime.fromisoformat( 134 created_str.replace("Z", "+00:00") 135 ) 136 except ValueError: 137 pass 138 139 batch_texts.append(content) 140 batch_records.append({ 141 "uri": uri, 142 "did": did, 143 "collection": collection, 144 "rkey": rkey, 145 "content": content, 146 "created_at": created_at, 147 "handle": handle, 148 }) 149 150 # Process in batches of 100 151 if len(batch_texts) >= 100: 152 if not dry_run: 153 _store_batch(engine, batch_texts, batch_records) 154 new += len(batch_texts) 155 batch_texts = [] 156 batch_records = [] 157 # Rate limit 158 time.sleep(0.5) 159 160 # Process remaining 161 if batch_texts: 162 if not dry_run: 163 _store_batch(engine, batch_texts, batch_records) 164 new += len(batch_texts) 165 166 if new > 0 or skipped > 0: 167 logger.info(f" {collection}: {new} new, {skipped} skipped") 168 total_new += new 169 total_skipped += skipped 170 171 logger.info(f" Total: {total_new} new, {total_skipped} skipped") 172 return total_new 173 174 175def _store_batch(engine, texts: list[str], records: list[dict]): 176 """Embed and store a batch of records.""" 177 try: 178 embs = embeddings.embed_batch(texts) 179 except Exception as e: 180 logger.error(f"Embedding failed: {e}") 181 return 182 183 session = db.get_session(engine) 184 try: 185 for rec, emb in zip(records, embs): 186 db.upsert_record( 187 session, 188 uri=rec["uri"], 189 did=rec["did"], 190 collection=rec["collection"], 191 rkey=rec["rkey"], 192 content=rec["content"], 193 embedding=emb, 194 created_at=rec["created_at"], 195 handle=rec["handle"], 196 ) 197 except Exception as e: 198 logger.error(f"Store failed: {e}") 199 session.rollback() 200 finally: 201 session.close() 202 203 204def main(): 205 parser = argparse.ArgumentParser(description="Backfill ATProto records into indexer") 206 parser.add_argument("--did", help="Backfill specific DID only") 207 parser.add_argument("--dry-run", action="store_true", help="Count records without indexing") 208 args = parser.parse_args() 209 210 engine = db.get_engine() 211 db.init_db(engine) 212 213 dids = [args.did] if args.did else sorted(SEED_DIDS) 214 collections = sorted(BASE_COLLECTIONS) 215 216 total = 0 217 for did in dids: 218 pds = resolve_pds(did) 219 if not pds: 220 continue 221 handle = resolve_handle(did) or did[:30] 222 count = backfill_agent(engine, did, handle, pds, collections, dry_run=args.dry_run) 223 total += count 224 225 logger.info(f"Backfill complete. {total} new records indexed.") 226 227 228if __name__ == "__main__": 229 main()