66from dataclasses import dataclass
77from pathlib import Path
88from typing import Any , Final
9- from urllib .parse import urlparse
109
1110from dotenv import load_dotenv
1211from upstash_search import Search
1716
1817
1918@dataclass (slots = True )
20- class ArticleRelevancyRow :
21- path : str
19+ class SearchRelevancyRow :
2220 title : str
21+ path : str
2322 score : float
2423
2524
@@ -61,132 +60,88 @@ def get_value(source: Any, key: str) -> Any:
6160 return getattr (source , key , None )
6261
6362
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 :
63+ def get_result_title (result : Any ) -> str :
10164 content = get_value (result , 'content' ) or {}
102- title = get_value (content , 'title' )
65+ title = get_value (content , 'title' ) or get_value ( content , 'name' ) or get_value ( result , 'id' )
10366 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 ()
67+ return title .strip ()
68+ return '(untitled result)'
11269
113- slug = article_path .rsplit ('/' , 1 )[- 1 ]
114- return slug_to_title (slug )
11570
71+ def get_result_path (result : Any ) -> str :
72+ content = get_value (result , 'content' ) or {}
73+ metadata = get_value (result , 'metadata' ) or {}
74+ path = (
75+ get_value (metadata , 'path' )
76+ or get_value (metadata , 'url' )
77+ or get_value (content , 'path' )
78+ or get_value (content , 'url' )
79+ )
11680
117- def collect_article_relevancy_rows ( results : list [ Any ]) -> list [ ArticleRelevancyRow ] :
118- grouped : dict [ str , ArticleRelevancyRow ] = {}
81+ if isinstance ( path , str ) and path . strip () :
82+ return path . strip ()
11983
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- )
84+ return '(no path)'
12985
130- article_path = get_base_article_path (path )
131- if not is_article_path (article_path ):
132- continue
13386
87+ def collect_search_relevancy_rows (results : list [Any ]) -> list [SearchRelevancyRow ]:
88+ rows : list [SearchRelevancyRow ] = []
89+
90+ for result in results :
13491 raw_score = get_value (result , 'score' )
13592 if not isinstance (raw_score , int | float ):
13693 continue
13794
138- candidate = ArticleRelevancyRow (
139- path = article_path ,
140- title = get_article_title (result , article_path ),
141- score = float (raw_score ),
95+ rows .append (
96+ SearchRelevancyRow (
97+ title = get_result_title (result ),
98+ path = get_result_path (result ),
99+ score = float (raw_score ),
100+ )
142101 )
143102
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 )
103+ return rows
160104
161105
162106def format_score (score : float ) -> str :
163107 return f'{ score :.6f} ' .rstrip ('0' ).rstrip ('.' )
164108
165109
166- def format_results_table (rows : list [ArticleRelevancyRow ]) -> str :
167- title_header = 'Article Title'
110+ def format_results_table (rows : list [SearchRelevancyRow ], * , show_path : bool = True ) -> str :
111+ title_header = 'Result Title'
112+ path_header = 'Path'
168113 score_header = 'Relevancy Score'
169114
170115 title_width = max ([len (title_header ), * (len (row .title ) for row in rows )])
116+ path_width = max ([len (path_header ), * (len (row .path ) for row in rows )]) if show_path else 0
171117 score_values = [format_score (row .score ) for row in rows ]
172118 score_width = max ([len (score_header ), * (len (value ) for value in score_values )])
173119
120+ if show_path :
121+ separator = f'+-{ '-' * title_width } -+-{ '-' * path_width } -+-{ '-' * score_width } -+'
122+ header = (
123+ f'| { title_header .ljust (title_width )} | '
124+ f'{ path_header .ljust (path_width )} | '
125+ f'{ score_header .rjust (score_width )} |'
126+ )
127+ body = [
128+ f'| { row .title .ljust (title_width )} | { row .path .ljust (path_width )} | { format_score (row .score ).rjust (score_width )} |'
129+ for row in rows
130+ ]
131+ return '\n ' .join ([separator , header , separator , * body , separator ])
132+
174133 separator = f'+-{ '-' * title_width } -+-{ '-' * score_width } -+'
175134 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- ]
135+ body = [f'| { row .title .ljust (title_width )} | { format_score (row .score ).rjust (score_width )} |' for row in rows ]
181136
182137 return '\n ' .join ([separator , header , separator , * body , separator ])
183138
184139
185- def run_search (* , query : str , limit : int , index_name : str | None = None ) -> list [ArticleRelevancyRow ]:
140+ def run_search (* , query : str , limit : int , index_name : str | None = None , reranking : bool = False ) -> list [SearchRelevancyRow ]:
186141 url , token , default_index_name = resolve_upstash_credentials ()
187142 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 )
143+ raw_results = client .index (index_name or default_index_name ).search (query , limit = limit , reranking = reranking )
144+ return collect_search_relevancy_rows (raw_results )
190145
191146
192147def parse_args (argv : list [str ]) -> argparse .Namespace :
@@ -196,6 +151,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
196151 parser .add_argument ('query' , help = 'Search query. Wrap multi-word queries in quotes.' )
197152 parser .add_argument ('--limit' , type = int , default = DEFAULT_LIMIT , help = 'Maximum Upstash results to request.' )
198153 parser .add_argument ('--index-name' , default = None , help = 'Override the Upstash index name.' )
154+ parser .add_argument ('--reranking' , action = 'store_true' , help = 'Enable Upstash reranking for the query.' )
155+ parser .add_argument ('--hide-path' , action = 'store_true' , help = 'Hide the path column from the output table.' )
199156 return parser .parse_args (argv )
200157
201158
@@ -204,16 +161,16 @@ def main(argv: list[str] | None = None) -> int:
204161
205162 try :
206163 load_environment ()
207- rows = run_search (query = args .query , limit = args .limit , index_name = args .index_name )
164+ rows = run_search (query = args .query , limit = args .limit , index_name = args .index_name , reranking = args . reranking )
208165 except Exception as exc : # noqa: BLE001
209166 print (f'[search:relevancy] { exc } ' , file = sys .stderr )
210167 return 1
211168
212169 if not rows :
213- print (f'No article results found for query: { args .query } ' )
170+ print (f'No results found for query: { args .query } ' )
214171 return 0
215172
216- print (format_results_table (rows ))
173+ print (format_results_table (rows , show_path = not args . hide_path ))
217174 return 0
218175
219176
0 commit comments