search for standard sites pub-search.waow.tech
search zig blog atproto

fix: include cover images in semantic and similar search results

Semantic and similar searches used tpuf.QueryResult which lacks
cover_image. Now fetchLocalExtras() fetches both snippets and
cover images from local SQLite in a single query per URI. Hybrid
search also falls back to local DB for semantic-only cover images.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+41 -20
+41 -20
backend/src/search.zig
··· 743 743 uri_count += 1; 744 744 } 745 745 746 - const snippets = fetchSnippets(alloc, uri_buf[0..uri_count]); 746 + const extras = fetchLocalExtras(alloc, uri_buf[0..uri_count]); 747 747 748 748 // serialize, filtering out the source URI 749 749 var output: std.Io.Writer.Allocating = .init(alloc); ··· 765 765 .uri = r.uri, 766 766 .did = r.did, 767 767 .title = r.title, 768 - .snippet = snippets.get(r.uri) orelse "", 768 + .snippet = extras.snippets.get(r.uri) orelse "", 769 769 .createdAt = r.created_at, 770 770 .rkey = r.rkey, 771 771 .basePath = r.base_path, 772 772 .platform = r.platform, 773 773 .path = r.path, 774 + .coverImage = extras.cover_images.get(r.uri) orelse "", 774 775 }); 775 776 count += 1; 776 777 } ··· 928 929 } 929 930 } 930 931 } 931 - const hybrid_snippets = fetchSnippets(alloc, sem_uri_buf[0..sem_uri_count]); 932 + const hybrid_extras = fetchLocalExtras(alloc, sem_uri_buf[0..sem_uri_count]); 932 933 933 934 // 7. serialize top 20 with source annotation 934 935 var output: std.Io.Writer.Allocating = .init(alloc); ··· 961 962 const existing = jsonStr(obj, "snippet"); 962 963 if (existing.len > 0) break :blk existing; 963 964 if (bits == 0b10) { 964 - break :blk hybrid_snippets.get(entry.uri) orelse ""; 965 + break :blk hybrid_extras.snippets.get(entry.uri) orelse ""; 965 966 } 966 967 break :blk existing; 967 968 }; ··· 974 975 } 975 976 try jw.objectField("snippet"); 976 977 try jw.write(snippet); 977 - inline for (.{ "rkey", "basePath", "platform", "path", "coverImage" }) |field| { 978 + inline for (.{ "rkey", "basePath", "platform", "path" }) |field| { 978 979 try jw.objectField(field); 979 980 try jw.write(jsonStr(obj, field)); 980 981 } 982 + // for semantic-only results, cover image may need local DB fallback 983 + const cover = blk: { 984 + const existing = jsonStr(obj, "coverImage"); 985 + if (existing.len > 0) break :blk existing; 986 + if (bits & 0b10 != 0) break :blk hybrid_extras.cover_images.get(entry.uri) orelse ""; 987 + break :blk existing; 988 + }; 989 + try jw.objectField("coverImage"); 990 + try jw.write(cover); 981 991 try jw.objectField("createdAt"); 982 992 try jw.write(jsonStr(obj, "createdAt")); 983 993 try jw.objectField("source"); ··· 1062 1072 filtered_count += 1; 1063 1073 } 1064 1074 1065 - // fetch content previews from local SQLite (arena-allocated, no explicit cleanup) 1066 - const snippets = fetchSnippets(alloc, seen[0..seen_count]); 1075 + // fetch content previews + cover images from local SQLite 1076 + const extras = fetchLocalExtras(alloc, seen[0..seen_count]); 1067 1077 1068 - // serialize results with snippets 1078 + // serialize results 1069 1079 var output: std.Io.Writer.Allocating = .init(alloc); 1070 1080 errdefer output.deinit(); 1071 1081 ··· 1078 1088 .uri = r.uri, 1079 1089 .did = r.did, 1080 1090 .title = r.title, 1081 - .snippet = snippets.get(r.uri) orelse "", 1091 + .snippet = extras.snippets.get(r.uri) orelse "", 1082 1092 .createdAt = r.created_at, 1083 1093 .rkey = r.rkey, 1084 1094 .basePath = r.base_path, 1085 1095 .platform = r.platform, 1086 1096 .path = r.path, 1097 + .coverImage = extras.cover_images.get(r.uri) orelse "", 1087 1098 }); 1088 1099 } 1089 1100 try jw.endArray(); ··· 1091 1102 return try output.toOwnedSlice(); 1092 1103 } 1093 1104 1094 - // --- snippet helpers (for semantic/similar results) --- 1105 + // --- local DB helpers (for semantic/similar results) --- 1106 + 1107 + /// Extra fields fetched from local SQLite for semantic/similar results. 1108 + const LocalExtras = struct { 1109 + snippets: std.StringHashMap([]const u8), 1110 + cover_images: std.StringHashMap([]const u8), 1111 + }; 1095 1112 1096 - /// Fetch content previews from local SQLite for a list of URIs. 1097 - /// Returns a map of URI -> preview text (first 200 chars of content). 1098 - /// Gracefully returns empty map if local db is unavailable. 1099 - fn fetchSnippets(alloc: Allocator, uris: []const []const u8) std.StringHashMap([]const u8) { 1100 - var map = std.StringHashMap([]const u8).init(alloc); 1101 - const local = db.getLocalDb() orelse return map; 1113 + /// Fetch content previews and cover images from local SQLite for a list of URIs. 1114 + /// Gracefully returns empty maps if local db is unavailable. 1115 + fn fetchLocalExtras(alloc: Allocator, uris: []const []const u8) LocalExtras { 1116 + var snippets = std.StringHashMap([]const u8).init(alloc); 1117 + var cover_images = std.StringHashMap([]const u8).init(alloc); 1118 + const local = db.getLocalDb() orelse return .{ .snippets = snippets, .cover_images = cover_images }; 1102 1119 for (uris) |uri| { 1103 1120 var rows = local.query( 1104 - "SELECT substr(content, 1, 200) FROM documents WHERE uri = ?", 1121 + "SELECT substr(content, 1, 200), COALESCE(cover_image, '') FROM documents WHERE uri = ?", 1105 1122 .{uri}, 1106 1123 ) catch continue; 1107 1124 defer rows.deinit(); 1108 1125 if (rows.next()) |row| { 1109 1126 const preview = row.text(0); 1110 1127 if (preview.len > 0) { 1111 - // dupe before rows.deinit() frees the backing memory 1112 1128 const duped = alloc.dupe(u8, preview) catch continue; 1113 - map.put(uri, duped) catch continue; 1129 + snippets.put(uri, duped) catch continue; 1130 + } 1131 + const cover = row.text(1); 1132 + if (cover.len > 0) { 1133 + const duped = alloc.dupe(u8, cover) catch continue; 1134 + cover_images.put(uri, duped) catch continue; 1114 1135 } 1115 1136 } 1116 1137 } 1117 - return map; 1138 + return .{ .snippets = snippets, .cover_images = cover_images }; 1118 1139 } 1119 1140 1120 1141 // --- JSON helpers (for hybrid search parsing) ---