Skip to content

Commit c6c2c52

Browse files
committed
Add search relevancy script
1 parent 78f3746 commit c6c2c52

11 files changed

Lines changed: 424 additions & 137 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"lint:style": "FORCE_COLOR=1 npx stylelint \"src/**/*.{css,astro}\"",
5656
"lint:tsc:check": "npm run sync && tsc --noEmit -p tsconfig.json --pretty false",
5757
"search:reindex": "python3 scripts/search-index.py",
58+
"search:relevancy": "python3 scripts/test_search_relevancy.py",
5859
"sync": "FORCE_COLOR=1 npx astro sync",
5960
"test": "npm run test:unit && npm run test:e2e",
6061
"test:coverage": "FORCE_COLOR=1 npx vitest run --coverage",

‎scripts/search_relevancy.py‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import os
5+
import sys
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
from typing import Any, Final
9+
from urllib.parse import urlparse
10+
11+
from dotenv import load_dotenv
12+
from upstash_search import Search
13+
14+
15+
DEFAULT_INDEX_NAME: Final[str] = 'default'
16+
DEFAULT_LIMIT: Final[int] = 10
17+
18+
19+
@dataclass(slots=True)
20+
class ArticleRelevancyRow:
21+
path: str
22+
title: str
23+
score: float
24+
25+
26+
def load_environment() -> None:
27+
repo_root = Path(__file__).resolve().parents[1]
28+
env_path = repo_root / '.env.development'
29+
if not env_path.exists():
30+
raise ValueError('Missing .env.development. Create it locally before using this script.')
31+
32+
load_dotenv(dotenv_path=env_path)
33+
34+
35+
def resolve_upstash_credentials() -> tuple[str, str, str]:
36+
index_name = (os.environ.get('UPSTASH_SEARCH_INDEX_NAME') or '').strip() or DEFAULT_INDEX_NAME
37+
38+
url = (
39+
(os.environ.get('UPSTASH_SEARCH_REST_URL') or '').strip()
40+
or (os.environ.get('PUBLIC_UPSTASH_SEARCH_REST_URL') or '').strip()
41+
)
42+
43+
token = (
44+
(os.environ.get('PUBLIC_UPSTASH_SEARCH_READONLY_TOKEN') or '').strip()
45+
or (os.environ.get('UPSTASH_SEARCH_REST_TOKEN') or '').strip()
46+
or (os.environ.get('UPSTASH_SEARCH_WRITE_TOKEN') or '').strip()
47+
or (os.environ.get('UPSTASH_SEARCH_REST_WRITE_TOKEN') or '').strip()
48+
)
49+
50+
if not url:
51+
raise ValueError('Missing UPSTASH_SEARCH_REST_URL or PUBLIC_UPSTASH_SEARCH_REST_URL')
52+
if not token:
53+
raise ValueError('Missing a usable Upstash Search token')
54+
55+
return url, token, index_name
56+
57+
58+
def get_value(source: Any, key: str) -> Any:
59+
if isinstance(source, dict):
60+
return source.get(key)
61+
return getattr(source, key, None)
62+
63+
64+
def normalize_path(value: Any) -> str:
65+
if not isinstance(value, str):
66+
return ''
67+
68+
trimmed = value.strip()
69+
if not trimmed:
70+
return ''
71+
72+
if trimmed.startswith('http://') or trimmed.startswith('https://'):
73+
parsed = urlparse(trimmed)
74+
path = parsed.path or ''
75+
suffix = ''
76+
if parsed.query:
77+
suffix = f'?{parsed.query}'
78+
if parsed.fragment:
79+
suffix = f'{suffix}#{parsed.fragment}'
80+
return f'{path}{suffix}'
81+
82+
return trimmed
83+
84+
85+
def get_base_article_path(path: str) -> str:
86+
without_query = path.split('?', 1)[0]
87+
without_fragment = without_query.split('#', 1)[0]
88+
return without_fragment.rstrip('/')
89+
90+
91+
def is_article_path(path: str) -> bool:
92+
return path.startswith('/articles/') and len(path.split('/')) >= 3
93+
94+
95+
def slug_to_title(slug: str) -> str:
96+
words = [word for word in slug.replace('_', '-').split('-') if word]
97+
return ' '.join(word.capitalize() for word in words)
98+
99+
100+
def get_article_title(result: Any, article_path: str) -> str:
101+
content = get_value(result, 'content') or {}
102+
title = get_value(content, 'title')
103+
if isinstance(title, str) and title.strip():
104+
result_path = normalize_path(
105+
get_value(get_value(result, 'metadata') or {}, 'path')
106+
or get_value(get_value(result, 'metadata') or {}, 'url')
107+
or get_value(content, 'path')
108+
or get_value(content, 'url')
109+
)
110+
if '#' not in result_path:
111+
return title.strip()
112+
113+
slug = article_path.rsplit('/', 1)[-1]
114+
return slug_to_title(slug)
115+
116+
117+
def collect_article_relevancy_rows(results: list[Any]) -> list[ArticleRelevancyRow]:
118+
grouped: dict[str, ArticleRelevancyRow] = {}
119+
120+
for result in results:
121+
metadata = get_value(result, 'metadata') or {}
122+
content = get_value(result, 'content') or {}
123+
path = normalize_path(
124+
get_value(metadata, 'path')
125+
or get_value(metadata, 'url')
126+
or get_value(content, 'path')
127+
or get_value(content, 'url')
128+
)
129+
130+
article_path = get_base_article_path(path)
131+
if not is_article_path(article_path):
132+
continue
133+
134+
raw_score = get_value(result, 'score')
135+
if not isinstance(raw_score, int | float):
136+
continue
137+
138+
candidate = ArticleRelevancyRow(
139+
path=article_path,
140+
title=get_article_title(result, article_path),
141+
score=float(raw_score),
142+
)
143+
144+
existing = grouped.get(article_path)
145+
if existing is None:
146+
grouped[article_path] = candidate
147+
continue
148+
149+
if candidate.score > existing.score:
150+
title = existing.title
151+
if '#' not in path and candidate.title.strip():
152+
title = candidate.title
153+
grouped[article_path] = ArticleRelevancyRow(path=article_path, title=title, score=candidate.score)
154+
continue
155+
156+
if '#' not in path and candidate.title.strip() and existing.title == slug_to_title(article_path.rsplit('/', 1)[-1]):
157+
grouped[article_path] = ArticleRelevancyRow(path=article_path, title=candidate.title, score=existing.score)
158+
159+
return sorted(grouped.values(), key=lambda row: row.score, reverse=True)
160+
161+
162+
def format_score(score: float) -> str:
163+
return f'{score:.6f}'.rstrip('0').rstrip('.')
164+
165+
166+
def format_results_table(rows: list[ArticleRelevancyRow]) -> str:
167+
title_header = 'Article Title'
168+
score_header = 'Relevancy Score'
169+
170+
title_width = max([len(title_header), *(len(row.title) for row in rows)])
171+
score_values = [format_score(row.score) for row in rows]
172+
score_width = max([len(score_header), *(len(value) for value in score_values)])
173+
174+
separator = f'+-{'-' * title_width}-+-{'-' * score_width}-+'
175+
header = f'| {title_header.ljust(title_width)} | {score_header.rjust(score_width)} |'
176+
177+
body = [
178+
f'| {row.title.ljust(title_width)} | {format_score(row.score).rjust(score_width)} |'
179+
for row in rows
180+
]
181+
182+
return '\n'.join([separator, header, separator, *body, separator])
183+
184+
185+
def run_search(*, query: str, limit: int, index_name: str | None = None) -> list[ArticleRelevancyRow]:
186+
url, token, default_index_name = resolve_upstash_credentials()
187+
client = Search(url=url, token=token)
188+
raw_results = client.index(index_name or default_index_name).search(query, limit=limit, reranking=True)
189+
return collect_article_relevancy_rows(raw_results)
190+
191+
192+
def parse_args(argv: list[str]) -> argparse.Namespace:
193+
parser = argparse.ArgumentParser(
194+
description='Query Upstash Search and print article relevancy scores in a table.'
195+
)
196+
parser.add_argument('query', help='Search query. Wrap multi-word queries in quotes.')
197+
parser.add_argument('--limit', type=int, default=DEFAULT_LIMIT, help='Maximum Upstash results to request.')
198+
parser.add_argument('--index-name', default=None, help='Override the Upstash index name.')
199+
return parser.parse_args(argv)
200+
201+
202+
def main(argv: list[str] | None = None) -> int:
203+
args = parse_args(argv or sys.argv[1:])
204+
205+
try:
206+
load_environment()
207+
rows = run_search(query=args.query, limit=args.limit, index_name=args.index_name)
208+
except Exception as exc: # noqa: BLE001
209+
print(f'[search:relevancy] {exc}', file=sys.stderr)
210+
return 1
211+
212+
if not rows:
213+
print(f'No article results found for query: {args.query}')
214+
return 0
215+
216+
print(format_results_table(rows))
217+
return 0
218+
219+
220+
if __name__ == '__main__':
221+
raise SystemExit(main())

‎scripts/test_search_relevancy.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from scripts.search_relevancy import ArticleRelevancyRow, collect_article_relevancy_rows, format_results_table
2+
3+
4+
def test_collect_article_relevancy_rows_groups_sections_by_article() -> None:
5+
rows = collect_article_relevancy_rows(
6+
[
7+
{
8+
'content': {'title': 'Introduction'},
9+
'metadata': {'path': '/articles/typescript-best-practices#introduction'},
10+
'score': 0.72,
11+
},
12+
{
13+
'content': {'title': 'TypeScript Best Practices for Modern Development'},
14+
'metadata': {'path': '/articles/typescript-best-practices'},
15+
'score': 1.0,
16+
},
17+
{
18+
'content': {'title': 'Case Study'},
19+
'metadata': {'path': '/case-studies/platform-migration'},
20+
'score': 0.95,
21+
},
22+
]
23+
)
24+
25+
assert rows == [
26+
ArticleRelevancyRow(
27+
path='/articles/typescript-best-practices',
28+
title='TypeScript Best Practices for Modern Development',
29+
score=1.0,
30+
)
31+
]
32+
33+
34+
def test_format_results_table_renders_two_columns() -> None:
35+
table = format_results_table(
36+
[
37+
ArticleRelevancyRow(path='/articles/one', title='Article One', score=1.0),
38+
ArticleRelevancyRow(path='/articles/two', title='Article Two', score=0.875),
39+
]
40+
)
41+
42+
assert 'Article Title' in table
43+
assert 'Relevancy Score' in table
44+
assert 'Article One' in table
45+
assert '0.875' in table

‎src/components/Pages/NotFound/index.astro‎

Lines changed: 0 additions & 21 deletions
This file was deleted.

‎src/components/Search/SearchEmptyState/index.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const items = [
4747
]
4848
---
4949

50-
<section class="space-y-6 mt-18 mb-12">
50+
<section class="space-y-6">
5151
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
5252
{items.map((item) => (
5353
<a href={item.link} class="group relative flex flex-col md:flex-row p-0 rounded-md border border-trim bg-page-base shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300 overflow-hidden no-underline hover:no-underline focus-visible:rounded-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-spotlight">
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
---
22
import SearchResults from '@components/Search/SearchResults/index.astro'
33
4-
const { query = 'typescript' } = Astro.props
4+
const {
5+
query = 'typescript',
6+
limit,
7+
showSearchBar,
8+
hideSearchEmptyState,
9+
} = Astro.props
510
---
611

7-
<SearchResults query={query} />
12+
<SearchResults
13+
query={query}
14+
limit={limit}
15+
showSearchBar={showSearchBar}
16+
hideSearchEmptyState={hideSearchEmptyState}
17+
/>

0 commit comments

Comments
 (0)