ranking_support.mojo (13568B)
1 from std.collections import List 2 3 from json import Value 4 from json.deserialize import get_float, get_int, get_string 5 6 from hyf_core.capabilities.query_analysis import ( 7 QueryAnalysis, 8 collapse_whitespace, 9 has_key, 10 normalize_free_text, 11 ) 12 from hyf_core.request_context import RequestContext 13 14 15 @fieldwise_init 16 struct SemanticCandidate(Copyable, Movable): 17 var id: String 18 var title: String 19 var farm: String 20 var delivery: String 21 var distance_km: Float64 22 var freshness_minutes: Int 23 24 25 @fieldwise_init 26 struct CandidateEvaluation(Copyable, Movable): 27 var candidate: SemanticCandidate 28 var score: Int 29 var reasons: List[String] 30 var matched_terms: List[String] 31 var delivery_alignment: String 32 var distance_band: String 33 var freshness_band: String 34 var scope_match: Bool 35 36 37 @fieldwise_init 38 struct SemanticRankRequest(Copyable, Movable): 39 var query_text: String 40 var candidates: List[SemanticCandidate] 41 42 43 @fieldwise_init 44 struct ExplainResultRequest(Copyable, Movable): 45 var query_text: String 46 var candidate: SemanticCandidate 47 48 49 def _require_object(value: Value, context: String) raises: 50 if not value.is_object(): 51 raise Error(context + " must be a JSON object") 52 53 54 def _require_allowed_keys( 55 value: Value, allowed_keys: List[String], context: String 56 ) raises: 57 for key in value.object_keys(): 58 var allowed = False 59 for allowed_key in allowed_keys: 60 if key == allowed_key: 61 allowed = True 62 break 63 if not allowed: 64 raise Error(context + " contains unexpected field '" + key + "'") 65 66 67 def _copy_candidate(candidate: SemanticCandidate) -> SemanticCandidate: 68 return SemanticCandidate( 69 id=String(candidate.id), 70 title=String(candidate.title), 71 farm=String(candidate.farm), 72 delivery=String(candidate.delivery), 73 distance_km=candidate.distance_km, 74 freshness_minutes=candidate.freshness_minutes, 75 ) 76 77 78 def _copy_string_list(items: List[String]) -> List[String]: 79 var copied = List[String]() 80 for item in items: 81 copied.append(String(item)) 82 return copied^ 83 84 85 def _copy_evaluation(evaluation: CandidateEvaluation) -> CandidateEvaluation: 86 return CandidateEvaluation( 87 candidate=_copy_candidate(evaluation.candidate), 88 score=evaluation.score, 89 reasons=_copy_string_list(evaluation.reasons), 90 matched_terms=_copy_string_list(evaluation.matched_terms), 91 delivery_alignment=String(evaluation.delivery_alignment), 92 distance_band=String(evaluation.distance_band), 93 freshness_band=String(evaluation.freshness_band), 94 scope_match=evaluation.scope_match, 95 ) 96 97 98 def _parse_candidate(json: Value, context: String) raises -> SemanticCandidate: 99 _require_object(json, context) 100 101 var allowed_keys = List[String]() 102 allowed_keys.append("id") 103 allowed_keys.append("title") 104 allowed_keys.append("farm") 105 allowed_keys.append("delivery") 106 allowed_keys.append("distance_km") 107 allowed_keys.append("freshness_minutes") 108 _require_allowed_keys(json, allowed_keys, context) 109 110 var id = get_string(json, "id") 111 if collapse_whitespace(id) == "": 112 raise Error(context + " field 'id' must not be empty") 113 114 var title = get_string(json, "title") 115 if collapse_whitespace(title) == "": 116 raise Error(context + " field 'title' must not be empty") 117 118 var farm = get_string(json, "farm") 119 if collapse_whitespace(farm) == "": 120 raise Error(context + " field 'farm' must not be empty") 121 122 var delivery = get_string(json, "delivery") 123 var normalized_delivery = collapse_whitespace(delivery).lower() 124 if normalized_delivery == "": 125 raise Error(context + " field 'delivery' must not be empty") 126 if normalized_delivery != "pickup" and normalized_delivery != "delivery": 127 raise Error( 128 context + " field 'delivery' must be one of 'pickup' or 'delivery'" 129 ) 130 131 var distance_km = get_float(json, "distance_km") 132 if distance_km < 0.0: 133 raise Error(context + " field 'distance_km' must be non-negative") 134 135 var freshness_minutes = get_int(json, "freshness_minutes") 136 if freshness_minutes < 0: 137 raise Error(context + " field 'freshness_minutes' must be non-negative") 138 139 return SemanticCandidate( 140 id=collapse_whitespace(id), 141 title=collapse_whitespace(title), 142 farm=collapse_whitespace(farm), 143 delivery=normalized_delivery, 144 distance_km=distance_km, 145 freshness_minutes=freshness_minutes, 146 ) 147 148 149 def _parse_query_text(input: Value, capability_name: String) raises -> String: 150 var field_count = 0 151 if has_key(input, "text"): 152 field_count += 1 153 if has_key(input, "query"): 154 field_count += 1 155 156 if field_count == 0: 157 raise Error( 158 capability_name + " input requires exactly one of 'text' or 'query'" 159 ) 160 if field_count > 1: 161 raise Error( 162 capability_name 163 + " input must provide exactly one of 'text' or 'query'" 164 ) 165 166 var field_name = "text" if has_key(input, "text") else "query" 167 var text_value = input[field_name] 168 if not text_value.is_string(): 169 raise Error( 170 capability_name 171 + " input field '" 172 + field_name 173 + "' must be a string" 174 ) 175 176 var collapsed = collapse_whitespace(text_value.string_value()) 177 if collapsed == "": 178 raise Error(capability_name + " input text must not be empty") 179 return collapsed^ 180 181 182 def parse_semantic_rank_request(input: Value) raises -> SemanticRankRequest: 183 _require_object(input, "semantic_rank input") 184 185 var allowed_keys = List[String]() 186 allowed_keys.append("text") 187 allowed_keys.append("query") 188 allowed_keys.append("candidates") 189 _require_allowed_keys(input, allowed_keys, "semantic_rank input") 190 191 return SemanticRankRequest( 192 query_text=_parse_query_text(input, "semantic_rank"), 193 candidates=parse_candidate_array(input, "semantic_rank"), 194 ) 195 196 197 def parse_explain_result_request(input: Value) raises -> ExplainResultRequest: 198 _require_object(input, "explain_result input") 199 200 var allowed_keys = List[String]() 201 allowed_keys.append("text") 202 allowed_keys.append("query") 203 allowed_keys.append("candidate") 204 allowed_keys.append("result") 205 _require_allowed_keys(input, allowed_keys, "explain_result input") 206 207 return ExplainResultRequest( 208 query_text=_parse_query_text(input, "explain_result"), 209 candidate=parse_single_candidate(input, "explain_result"), 210 ) 211 212 213 def parse_candidate_array( 214 input: Value, capability_name: String 215 ) raises -> List[SemanticCandidate]: 216 _require_object(input, capability_name + " input") 217 218 if not has_key(input, "candidates"): 219 raise Error(capability_name + " input requires 'candidates'") 220 221 var candidates_value = input["candidates"] 222 if not candidates_value.is_array(): 223 raise Error( 224 capability_name + " input field 'candidates' must be a JSON array" 225 ) 226 227 var candidates = List[SemanticCandidate]() 228 var seen_ids = List[String]() 229 for item in candidates_value.array_items(): 230 var candidate = _parse_candidate(item, capability_name + " candidate") 231 for seen_id in seen_ids: 232 if seen_id == candidate.id: 233 raise Error( 234 capability_name 235 + " input contains duplicate candidate id '" 236 + candidate.id 237 + "'" 238 ) 239 seen_ids.append(String(candidate.id)) 240 candidates.append(candidate^) 241 242 if len(candidates) == 0: 243 raise Error( 244 capability_name + " input field 'candidates' must not be empty" 245 ) 246 247 return candidates^ 248 249 250 def parse_single_candidate( 251 input: Value, capability_name: String 252 ) raises -> SemanticCandidate: 253 _require_object(input, capability_name + " input") 254 255 var field_count = 0 256 if has_key(input, "candidate"): 257 field_count += 1 258 if has_key(input, "result"): 259 field_count += 1 260 261 if field_count == 0: 262 raise Error(capability_name + " input requires 'candidate' or 'result'") 263 if field_count > 1: 264 raise Error( 265 capability_name 266 + " input must not include both 'candidate' and 'result'" 267 ) 268 269 if has_key(input, "candidate"): 270 return _parse_candidate( 271 input["candidate"], capability_name + " candidate" 272 ) 273 274 return _parse_candidate(input["result"], capability_name + " result") 275 276 277 def _normalize_candidate_text(candidate: SemanticCandidate) -> String: 278 var signals = List[String]() 279 return normalize_free_text(candidate.title + " " + candidate.farm, signals) 280 281 282 def _display_term(term: String) -> String: 283 return String(term) 284 285 286 def _delivery_alignment( 287 query_delivery: String, candidate_delivery: String 288 ) -> String: 289 if query_delivery == "unspecified": 290 return "not_requested" 291 if query_delivery == candidate_delivery: 292 return "match" 293 return "mismatch" 294 295 296 def _distance_band(local_intent: Bool, distance_km: Float64) -> String: 297 if not local_intent: 298 return "not_considered" 299 if distance_km <= 5.0: 300 return "closer" 301 return "farther" 302 303 304 def _freshness_band(freshness_minutes: Int) -> String: 305 if freshness_minutes <= 10: 306 return "fresher" 307 if freshness_minutes <= 30: 308 return "standard" 309 return "older" 310 311 312 def _scope_match(candidate: SemanticCandidate, context: RequestContext) -> Bool: 313 if not context.scope: 314 return False 315 316 var scope = context.scope.value().copy() 317 for listing_id in scope.listing_ids: 318 if listing_id == candidate.id: 319 return True 320 return False 321 322 323 def evaluate_candidate( 324 candidate: SemanticCandidate, 325 analysis: QueryAnalysis, 326 context: RequestContext, 327 ) -> CandidateEvaluation: 328 var reasons = List[String]() 329 var matched_terms = List[String]() 330 var score = 0 331 332 var normalized_candidate_text = _normalize_candidate_text(candidate) 333 var scope_match = _scope_match(candidate, context) 334 if scope_match: 335 reasons.append("scope match") 336 score += 40 337 338 for query_term in analysis.query_terms: 339 var matched = False 340 for candidate_term in normalized_candidate_text.split(): 341 if String(candidate_term) == query_term: 342 matched = True 343 break 344 if matched: 345 matched_terms.append(String(query_term)) 346 score += 30 347 348 if len(matched_terms) > 0: 349 reasons.append(_display_term(matched_terms[0]) + " match") 350 351 var delivery_alignment = _delivery_alignment( 352 analysis.extracted_filters.fulfillment, candidate.delivery 353 ) 354 if delivery_alignment == "match": 355 reasons.append(candidate.delivery + " match") 356 score += 35 357 elif delivery_alignment == "mismatch": 358 reasons.append("delivery mismatch") 359 score -= 20 360 361 var distance_band = _distance_band( 362 analysis.extracted_filters.local_intent, candidate.distance_km 363 ) 364 if distance_band == "closer": 365 reasons.append("closer") 366 score += 20 367 elif distance_band == "farther": 368 reasons.append("farther") 369 score += 5 370 371 var freshness_band = _freshness_band(candidate.freshness_minutes) 372 if freshness_band == "fresher": 373 reasons.append("fresher") 374 score += 15 375 elif freshness_band == "older": 376 score -= 5 377 378 if analysis.extracted_filters.time_window == "weekend": 379 score += 2 380 381 return CandidateEvaluation( 382 candidate=_copy_candidate(candidate), 383 score=score, 384 reasons=reasons^, 385 matched_terms=matched_terms^, 386 delivery_alignment=delivery_alignment, 387 distance_band=distance_band, 388 freshness_band=freshness_band, 389 scope_match=scope_match, 390 ) 391 392 393 def _should_precede( 394 pending: CandidateEvaluation, existing: CandidateEvaluation 395 ) -> Bool: 396 if pending.score != existing.score: 397 return pending.score > existing.score 398 399 if pending.scope_match != existing.scope_match: 400 return pending.scope_match 401 402 if len(pending.matched_terms) != len(existing.matched_terms): 403 return len(pending.matched_terms) > len(existing.matched_terms) 404 405 if pending.candidate.distance_km != existing.candidate.distance_km: 406 return pending.candidate.distance_km < existing.candidate.distance_km 407 408 if ( 409 pending.candidate.freshness_minutes 410 != existing.candidate.freshness_minutes 411 ): 412 return ( 413 pending.candidate.freshness_minutes 414 < existing.candidate.freshness_minutes 415 ) 416 417 return pending.candidate.id < existing.candidate.id 418 419 420 def rank_candidates( 421 candidates: List[SemanticCandidate], 422 analysis: QueryAnalysis, 423 context: RequestContext, 424 ) -> List[CandidateEvaluation]: 425 var ranked = List[CandidateEvaluation]() 426 for candidate in candidates: 427 var pending = evaluate_candidate(candidate, analysis, context) 428 var updated = List[CandidateEvaluation]() 429 var inserted = False 430 for existing in ranked: 431 if not inserted and _should_precede(pending, existing): 432 updated.append(_copy_evaluation(pending)) 433 inserted = True 434 updated.append(_copy_evaluation(existing)) 435 if not inserted: 436 updated.append(_copy_evaluation(pending)) 437 ranked = updated^ 438 return ranked^