This repository has no description
0

Configure Feed

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

pensieve / src / record_text.zig
6.8 kB 189 lines
1//! record_to_text heuristic — port of embedder/build_pack.py. 2//! 3//! walks arbitrary atproto record JSON and produces embedding-ready text. 4//! rules: 5//! - prepend `collection: <nsid>` so the lex name is in the vector 6//! - drop atproto plumbing keys ($type, cid, rev, sig, prev, version, $link) 7//! - drop identifier-shaped strings (DIDs, at-uris, CIDs, TID rkeys, hex hashes) 8//! - convert iso timestamps to year only (keeps temporal signal, not noise) 9//! - render as flat `key.path: value` lines 10//! - strongRefs and DIDs are currently dropped (v2: deref via slingshot) 11//! - truncate final text at 4000 chars 12 13const std = @import("std"); 14const json = std.json; 15const Allocator = std.mem.Allocator; 16 17const MAX_TEXT_LEN = 4000; 18 19const NOISE_KEYS = [_][]const u8{ 20 "$type", "cid", "rev", "sig", "prev", "version", "$link", 21}; 22 23fn isNoiseKey(k: []const u8) bool { 24 if (k.len == 0) return false; 25 if (k[0] == '$') return true; 26 for (NOISE_KEYS) |n| if (std.mem.eql(u8, k, n)) return true; 27 return false; 28} 29 30fn isIdentifier(s: []const u8) bool { 31 if (s.len == 0) return false; 32 if (std.mem.startsWith(u8, s, "did:plc:")) return true; 33 if (std.mem.startsWith(u8, s, "did:web:")) return true; 34 if (std.mem.startsWith(u8, s, "at://")) return true; 35 // CID v1 raw/dag-cbor 36 if (std.mem.startsWith(u8, s, "bafy")) return true; 37 if (std.mem.startsWith(u8, s, "bafk")) return true; 38 // TID format: exactly 13 chars, lowercase base32 subset [2-7a-z] 39 if (s.len == 13) { 40 var all_tid = true; 41 for (s) |ch| { 42 const ok = (ch >= 'a' and ch <= 'z') or (ch >= '2' and ch <= '7'); 43 if (!ok) { 44 all_tid = false; 45 break; 46 } 47 } 48 if (all_tid) return true; 49 } 50 // hex hash of 32+ chars 51 if (s.len >= 32) { 52 var all_hex = true; 53 for (s) |ch| { 54 const ok = (ch >= '0' and ch <= '9') or (ch >= 'a' and ch <= 'f'); 55 if (!ok) { 56 all_hex = false; 57 break; 58 } 59 } 60 if (all_hex) return true; 61 } 62 return false; 63} 64 65fn looksLikeIsoTimestamp(s: []const u8) bool { 66 // YYYY-MM-DDT... pattern (loose) 67 if (s.len < 11) return false; 68 for (0..4) |i| if (!std.ascii.isDigit(s[i])) return false; 69 if (s[4] != '-') return false; 70 for (5..7) |i| if (!std.ascii.isDigit(s[i])) return false; 71 if (s[7] != '-') return false; 72 for (8..10) |i| if (!std.ascii.isDigit(s[i])) return false; 73 return s[10] == 'T' or s[10] == ' '; 74} 75 76/// shape check: is this a strongRef {uri: "at://...", cid: "bafy..."}? 77fn isStrongRef(v: json.Value) bool { 78 if (v != .object) return false; 79 const uri_v = v.object.get("uri") orelse return false; 80 const cid_v = v.object.get("cid") orelse return false; 81 if (uri_v != .string or cid_v != .string) return false; 82 return std.mem.startsWith(u8, uri_v.string, "at://"); 83} 84 85pub const Builder = struct { 86 buf: std.ArrayList(u8), 87 allocator: Allocator, 88 89 pub fn init(allocator: Allocator) Builder { 90 return .{ .buf = .empty, .allocator = allocator }; 91 } 92 93 fn appendLine(self: *Builder, path: []const u8, value: []const u8) !void { 94 if (self.buf.items.len >= MAX_TEXT_LEN) return; 95 if (self.buf.items.len > 0) try self.buf.append(self.allocator, '\n'); 96 if (path.len > 0) { 97 try self.buf.appendSlice(self.allocator, path); 98 try self.buf.appendSlice(self.allocator, ": "); 99 } 100 try self.buf.appendSlice(self.allocator, value); 101 } 102}; 103 104/// walk a parsed record value, appending flattened text to `builder`. 105fn walk(builder: *Builder, node: json.Value, path: []const u8) anyerror!void { 106 if (builder.buf.items.len >= MAX_TEXT_LEN) return; 107 108 // strongRef short-circuit: drop (v2: inline dereffed content) 109 if (isStrongRef(node)) return; 110 111 switch (node) { 112 .object => |obj| { 113 var it = obj.iterator(); 114 while (it.next()) |entry| { 115 const k = entry.key_ptr.*; 116 if (isNoiseKey(k)) continue; 117 118 const next_path = if (path.len == 0) 119 try builder.allocator.dupe(u8, k) 120 else 121 try std.fmt.allocPrint(builder.allocator, "{s}.{s}", .{ path, k }); 122 123 try walk(builder, entry.value_ptr.*, next_path); 124 } 125 }, 126 .array => |arr| { 127 // pure string arrays → comma-joined 128 var all_strings = true; 129 for (arr.items) |item| { 130 if (item != .string) { 131 all_strings = false; 132 break; 133 } 134 } 135 if (all_strings) { 136 var kept: std.ArrayList([]const u8) = .empty; 137 for (arr.items) |item| { 138 if (!isIdentifier(item.string)) { 139 try kept.append(builder.allocator, item.string); 140 } 141 } 142 if (kept.items.len > 0) { 143 const joined = try std.mem.join(builder.allocator, ", ", kept.items); 144 try builder.appendLine(path, joined); 145 } 146 } else { 147 for (arr.items) |item| try walk(builder, item, path); 148 } 149 }, 150 .string => |s| { 151 // DID reference → drop (v2: inline profile) 152 if (std.mem.startsWith(u8, s, "did:plc:") or std.mem.startsWith(u8, s, "did:web:")) return; 153 if (isIdentifier(s)) return; 154 if (looksLikeIsoTimestamp(s)) { 155 try builder.appendLine(path, s[0..4]); // keep year only 156 return; 157 } 158 try builder.appendLine(path, s); 159 }, 160 .integer => |i| { 161 if (i == 0) return; 162 const s = try std.fmt.allocPrint(builder.allocator, "{d}", .{i}); 163 try builder.appendLine(path, s); 164 }, 165 .float => |f| { 166 if (f == 0.0) return; 167 const s = try std.fmt.allocPrint(builder.allocator, "{d}", .{f}); 168 try builder.appendLine(path, s); 169 }, 170 .bool => return, // rarely meaningful for retrieval 171 .null => return, 172 .number_string => |s| try builder.appendLine(path, s), 173 } 174} 175 176/// produce embedding-ready text for a record. result is allocated in `arena` 177/// and truncated at MAX_TEXT_LEN. 178pub fn recordToText( 179 arena: Allocator, 180 collection: []const u8, 181 value: json.Value, 182) ![]const u8 { 183 var b = Builder.init(arena); 184 // prepend the NSID — strongest structural signal 185 try b.appendLine("collection", collection); 186 try walk(&b, value, ""); 187 const len = @min(b.buf.items.len, MAX_TEXT_LEN); 188 return b.buf.items[0..len]; 189}