Skip to content

Commit bdd55c3

Browse files
authored
feat: add ranking explanation page + raw score and semantic score + label link (#53)
* feat: add ranking explanation page * add link to issues filtered by label --------- Co-authored-by: zx <[email protected]>
1 parent 8fcda7e commit bdd55c3

File tree

8 files changed

+254
-57
lines changed

8 files changed

+254
-57
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// would need to recalibrate normalization + score colors in IssueCard.tsx
2+
// if we change weights/algorithm here
3+
4+
// Ranking weights (should sum to 1)
5+
export const RANKING_WEIGHTS = {
6+
SEMANTIC_SIMILARITY: 0.8, // Start with higher weight for semantic search
7+
COMMENT_COUNT: 0.12, // Activity level
8+
RECENCY: 0.05, // Recent updates
9+
ISSUE_STATE: 0.03, // Small bonus for open issues
10+
} as const;
11+
12+
// Time-based constants
13+
export const TIME_CONSTANTS = {
14+
// Base time unit in days for recency calculation
15+
RECENCY_BASE_DAYS: 30,
16+
} as const;
17+
18+
export const COMMENT_COUNT_CAP = 80;
19+
20+
// Score multipliers
21+
export const SCORE_MULTIPLIERS = {
22+
OPEN_ISSUE: 1.0,
23+
CLOSED_ISSUE: 0.8,
24+
} as const;
25+
26+
export const NORMALIZATION_ANCHOR = 0.65;

packages/core/src/semsearch/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ async function filterAfterVectorSearch(
185185
let query = tx
186186
.select({
187187
...getBaseSelect(),
188+
similarityScore,
188189
rankingScore,
189190
// Add a window function to get the total count in the same query
190191
totalCount: sql<number>`count(*) over()`.as("total_count"),
@@ -278,6 +279,7 @@ async function filterBeforeVectorSearch(
278279
repoLastSyncedAt: vectorSearchSubquery.repoLastSyncedAt,
279280
commentCount: vectorSearchSubquery.commentCount,
280281
rankingScore,
282+
similarityScore,
281283
// Add window function to get total count in same query
282284
totalCount: sql<number>`count(*) over()`.as("total_count"),
283285
})

packages/core/src/semsearch/ranking.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,13 @@
1+
import {
2+
COMMENT_COUNT_CAP,
3+
RANKING_WEIGHTS,
4+
SCORE_MULTIPLIERS,
5+
TIME_CONSTANTS,
6+
} from "@/constants/ranking.constant";
17
import type { AnyColumn, SQL } from "@/db";
28
import { sql } from "@/db";
39
import { comments } from "@/db/schema/entities/comment.sql";
410

5-
// would need to recalibrate normalization + score colors in IssueCard.tsx
6-
// if we change weights/algorithm here
7-
8-
// Ranking weights (should sum to 1)
9-
const RANKING_WEIGHTS = {
10-
SEMANTIC_SIMILARITY: 0.8, // Start with higher weight for semantic search
11-
COMMENT_COUNT: 0.12, // Activity level
12-
RECENCY: 0.05, // Recent updates
13-
ISSUE_STATE: 0.03, // Small bonus for open issues
14-
} as const;
15-
16-
// Time-based constants
17-
const TIME_CONSTANTS = {
18-
// Base time unit in days for recency calculation
19-
RECENCY_BASE_DAYS: 30,
20-
} as const;
21-
22-
// Score multipliers
23-
const SCORE_MULTIPLIERS = {
24-
OPEN_ISSUE: 1.0,
25-
CLOSED_ISSUE: 0.8,
26-
} as const;
2711
/**
2812
* Calculates recency score using exponential decay
2913
* exp(-t/τ) where:
@@ -57,7 +41,7 @@ export function calculateRecencyScore(issueUpdatedAtColumn: SQL | AnyColumn) {
5741
export function calculateCommentScore(issueId: SQL | AnyColumn) {
5842
return sql<number>`
5943
LN(GREATEST((SELECT count(*) FROM ${comments} WHERE ${comments.issueId} = ${issueId})::float + 1, 1)) /
60-
LN(51) -- ln(50 + 1) ≈ 3.93 as normalizing factor
44+
LN(${COMMENT_COUNT_CAP} + 1)
6145
`;
6246
}
6347

packages/core/src/semsearch/schema.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,8 @@ const searchIssueSchema = createSelectSchema(issueTable, {
6767
// Search-specific fields
6868
commentCount: z.number(),
6969
rankingScore: z.number(),
70-
})
71-
.transform((issue) => ({
72-
...issue,
73-
}));
70+
similarityScore: z.number(),
71+
});
7472

7573
export const searchResultSchema = z.object({
7674
data: z.array(searchIssueSchema),

packages/web/src/components/search/IssueCard.tsx

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Link } from "@tanstack/react-router";
12
import DOMPurify from "dompurify";
23
import {
34
CircleCheckIcon,
@@ -6,6 +7,7 @@ import {
67
MessageSquareIcon,
78
} from "lucide-react";
89

10+
import { NORMALIZATION_ANCHOR } from "@/core/constants/ranking.constant";
911
import type { AggregateReactions } from "@/core/db/schema/shared";
1012
import type { PublicSearchIssuesResponse } from "@/lib/api/search";
1113
import { formatLocalDateTime, getTimeAgo } from "@/lib/time";
@@ -118,11 +120,8 @@ function RepoTag({ issue }: { issue: Issue }) {
118120
);
119121
}
120122

121-
// would need to recalibrate normalization + score colors based on weights
122-
// and algorithm used in semsearch/ranking.ts
123123
function normalizeScore(rawScore: number): number {
124-
const ANCHOR = 0.65;
125-
const normalizedScore = (rawScore / ANCHOR) * 100;
124+
const normalizedScore = (rawScore / NORMALIZATION_ANCHOR) * 100;
126125
return Math.min(normalizedScore, 100);
127126
}
128127

@@ -133,12 +132,26 @@ function getScoreColor(rawScore: number): string {
133132
return "bg-gray-100 text-gray-800";
134133
}
135134

136-
function ScoreBadge({ score }: { score: number }) {
137-
const normalizedScore = normalizeScore(score);
138-
const colorClass = getScoreColor(score);
135+
function ScoreBadge({
136+
rankingScore,
137+
similarityScore,
138+
}: {
139+
rankingScore: number;
140+
similarityScore: number;
141+
}) {
142+
const normalizedScore = normalizeScore(rankingScore);
143+
const colorClass = getScoreColor(rankingScore);
144+
const toolTipContent = (
145+
<>
146+
Raw score: {(rankingScore * 100).toFixed(1)}%
147+
<br />
148+
Similarity: {(similarityScore * 100).toFixed(1)}%
149+
<br />
150+
<Link to="/ranking">Learn more</Link>
151+
</>
152+
);
139153
return (
140-
// TODO: add more scores showing breakdown?
141-
<FastTooltip content={`Raw score: ${(score * 100).toFixed(1)}%`}>
154+
<FastTooltip content={toolTipContent}>
142155
<span
143156
className={`mr-1.5 inline-flex rounded-md px-1.5 py-0.5 text-sm font-medium ${colorClass}`}
144157
>
@@ -150,17 +163,28 @@ function ScoreBadge({ score }: { score: number }) {
150163

151164
function IssueTitleWithLabels({ issue }: { issue: Issue }) {
152165
const renderLabel = (label: Issue["labels"][number]) => {
166+
// Get the base repo URL by removing '/issues/number' from the issue URL
167+
const repoBaseUrl = issue.issueUrl.split("/issues/")[0];
168+
const labelFilterUrl = `${repoBaseUrl}/issues?q=is:issue+label:"${encodeURIComponent(label.name)}"`;
169+
153170
const badgeElement = (
154-
<Badge
155-
variant="secondary"
156-
className="mx-1 inline-flex rounded-full px-2 py-0.5"
157-
style={{
158-
backgroundColor: `#${label.color}`,
159-
color: `${parseInt(label.color, 16) > 0x7fffff ? "#000" : "#fff"}`,
160-
}}
171+
<a
172+
href={labelFilterUrl}
173+
target="_blank"
174+
rel="noopener noreferrer"
175+
onClick={(e) => e.stopPropagation()}
161176
>
162-
{label.name}
163-
</Badge>
177+
<Badge
178+
variant="secondary"
179+
className="mx-1 inline-flex rounded-full px-2 py-0.5 hover:opacity-80"
180+
style={{
181+
backgroundColor: `#${label.color}`,
182+
color: `${parseInt(label.color, 16) > 0x7fffff ? "#000" : "#fff"}`,
183+
}}
184+
>
185+
{label.name}
186+
</Badge>
187+
</a>
164188
);
165189

166190
// description can be null or empty string
@@ -178,7 +202,10 @@ function IssueTitleWithLabels({ issue }: { issue: Issue }) {
178202

179203
return (
180204
<div className="min-w-0 grow text-lg font-semibold">
181-
<ScoreBadge score={issue.rankingScore} />
205+
<ScoreBadge
206+
rankingScore={issue.rankingScore}
207+
similarityScore={issue.similarityScore}
208+
/>
182209
<a
183210
href={issue.issueUrl}
184211
target="_blank"

packages/web/src/routeTree.gen.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import { Route as rootRoute } from './routes/__root'
1414
import { Route as SearchImport } from './routes/search'
15+
import { Route as RankingImport } from './routes/ranking'
1516
import { Route as IndexImport } from './routes/index'
1617
import { Route as ReposIndexImport } from './routes/repos/index'
1718
import { Route as ReposSearchImport } from './routes/repos/search'
@@ -25,6 +26,11 @@ const SearchRoute = SearchImport.update({
2526
getParentRoute: () => rootRoute,
2627
} as any)
2728

29+
const RankingRoute = RankingImport.update({
30+
path: '/ranking',
31+
getParentRoute: () => rootRoute,
32+
} as any)
33+
2834
const IndexRoute = IndexImport.update({
2935
path: '/',
3036
getParentRoute: () => rootRoute,
@@ -61,6 +67,13 @@ declare module '@tanstack/react-router' {
6167
preLoaderRoute: typeof IndexImport
6268
parentRoute: typeof rootRoute
6369
}
70+
'/ranking': {
71+
id: '/ranking'
72+
path: '/ranking'
73+
fullPath: '/ranking'
74+
preLoaderRoute: typeof RankingImport
75+
parentRoute: typeof rootRoute
76+
}
6477
'/search': {
6578
id: '/search'
6679
path: '/search'
@@ -103,6 +116,7 @@ declare module '@tanstack/react-router' {
103116

104117
export interface FileRoutesByFullPath {
105118
'/': typeof IndexRoute
119+
'/ranking': typeof RankingRoute
106120
'/search': typeof SearchRoute
107121
'/repos/search': typeof ReposSearchRoute
108122
'/repos': typeof ReposIndexRoute
@@ -112,6 +126,7 @@ export interface FileRoutesByFullPath {
112126

113127
export interface FileRoutesByTo {
114128
'/': typeof IndexRoute
129+
'/ranking': typeof RankingRoute
115130
'/search': typeof SearchRoute
116131
'/repos/search': typeof ReposSearchRoute
117132
'/repos': typeof ReposIndexRoute
@@ -122,6 +137,7 @@ export interface FileRoutesByTo {
122137
export interface FileRoutesById {
123138
__root__: typeof rootRoute
124139
'/': typeof IndexRoute
140+
'/ranking': typeof RankingRoute
125141
'/search': typeof SearchRoute
126142
'/repos/search': typeof ReposSearchRoute
127143
'/repos/': typeof ReposIndexRoute
@@ -133,6 +149,7 @@ export interface FileRouteTypes {
133149
fileRoutesByFullPath: FileRoutesByFullPath
134150
fullPaths:
135151
| '/'
152+
| '/ranking'
136153
| '/search'
137154
| '/repos/search'
138155
| '/repos'
@@ -141,6 +158,7 @@ export interface FileRouteTypes {
141158
fileRoutesByTo: FileRoutesByTo
142159
to:
143160
| '/'
161+
| '/ranking'
144162
| '/search'
145163
| '/repos/search'
146164
| '/repos'
@@ -149,6 +167,7 @@ export interface FileRouteTypes {
149167
id:
150168
| '__root__'
151169
| '/'
170+
| '/ranking'
152171
| '/search'
153172
| '/repos/search'
154173
| '/repos/'
@@ -159,6 +178,7 @@ export interface FileRouteTypes {
159178

160179
export interface RootRouteChildren {
161180
IndexRoute: typeof IndexRoute
181+
RankingRoute: typeof RankingRoute
162182
SearchRoute: typeof SearchRoute
163183
ReposSearchRoute: typeof ReposSearchRoute
164184
ReposIndexRoute: typeof ReposIndexRoute
@@ -168,6 +188,7 @@ export interface RootRouteChildren {
168188

169189
const rootRouteChildren: RootRouteChildren = {
170190
IndexRoute: IndexRoute,
191+
RankingRoute: RankingRoute,
171192
SearchRoute: SearchRoute,
172193
ReposSearchRoute: ReposSearchRoute,
173194
ReposIndexRoute: ReposIndexRoute,
@@ -188,6 +209,7 @@ export const routeTree = rootRoute
188209
"filePath": "__root.tsx",
189210
"children": [
190211
"/",
212+
"/ranking",
191213
"/search",
192214
"/repos/search",
193215
"/repos/",
@@ -198,6 +220,9 @@ export const routeTree = rootRoute
198220
"/": {
199221
"filePath": "index.tsx"
200222
},
223+
"/ranking": {
224+
"filePath": "ranking.tsx"
225+
},
201226
"/search": {
202227
"filePath": "search.tsx"
203228
},

packages/web/src/routes/r/your/repo.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,34 +29,29 @@ function YourRepoPage() {
2929
<span className="text-blue-600 dark:text-blue-500">Sem</span>
3030
<span className="text-orange-500">Hub</span> a try
3131
</h2>
32-
<ul className="flex flex-col gap-3 text-left text-muted-foreground">
33-
<li className="flex items-center gap-3">
34-
<div className="size-1.5 rounded-full bg-blue-600/40" />
32+
<ul className="flex list-disc flex-col gap-3 pl-5 text-left text-muted-foreground marker:text-blue-600/40">
33+
<li>
3534
<span>
3635
Help users find answers faster with semantic search
3736
</span>
3837
</li>
39-
<li className="flex items-center gap-3">
40-
<div className="size-1.5 rounded-full bg-blue-600/40" />
38+
<li>
4139
<span>
4240
Reduce duplicate issues by making existing ones discoverable
4341
</span>
4442
</li>
45-
<li className="flex items-center gap-3">
46-
<div className="size-1.5 rounded-full bg-blue-600/40" />
43+
<li>
4744
<span>Simple setup - just add a badge to your README</span>
4845
</li>
49-
<li className="flex items-center gap-3">
50-
<div className="size-1.5 rounded-full bg-blue-600/20" />
46+
<li className="marker:!text-blue-600/20">
5147
<span className="[word-break:break-word]">
5248
Search across pull requests and discussions
5349
<Badge variant="coming-soon" className="ml-2">
5450
Coming soon
5551
</Badge>
5652
</span>
5753
</li>
58-
<li className="flex items-center gap-3">
59-
<div className="size-1.5 rounded-full bg-blue-600/20" />
54+
<li className="marker:!text-blue-600/20">
6055
<span className="[word-break:break-word]">
6156
Search across a collection of multiple repos, including
6257
private repos

0 commit comments

Comments
 (0)