Levimichael4 commited on
Commit
cd41536
·
verified ·
1 Parent(s): eb25f6f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +615 -373
app.py CHANGED
@@ -1,419 +1,661 @@
1
- # app.py — RideSearch (Fixed trim levels and 0-100 acceleration)
2
 
3
- import os, glob, urllib.parse
4
  import numpy as np
5
  import pandas as pd
6
- from sklearn.metrics.pairwise import cosine_similarity
7
- from sklearn.preprocessing import StandardScaler
8
  import gradio as gr
9
- import json
 
 
 
 
 
 
 
 
10
 
11
- # =========================
12
- # Data loading & embeddings
13
- # =========================
 
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def load_df():
16
- if os.path.exists('RideSearch_dataset.csv'):
17
- return pd.read_csv('RideSearch_dataset.csv')
18
- parts = sorted(glob.glob('RideSearch_part*_small.csv'))
19
  if not parts:
20
  raise FileNotFoundError("Upload RideSearch_dataset.csv OR the 10 parts RideSearch_part*_small.csv.")
21
  df = pd.concat([pd.read_csv(p) for p in parts], ignore_index=True)
22
- df.to_csv('RideSearch_dataset.csv', index=False)
23
  return df
24
 
25
- DF = load_df()
26
-
27
- NUM_COLS = [
28
- 'horsepower','zero_to_100_kmh_s','seats','cargo_liters','price_usd',
29
- 'popularity_score','comfort_score','reliability_score','tech_score',
30
- 'ownership_cost_score','safety_rating'
31
- ]
32
-
33
- def load_trims_map():
34
- """Load the trims mapping from JSON file if it exists"""
35
- try:
36
- with open('trims_map.json', 'r') as f:
37
- return json.load(f)
38
- except FileNotFoundError:
39
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- TRIMS_MAP = load_trims_map()
42
 
43
- def ensure_emb():
44
- txt_ok = os.path.exists('emb_text.npy')
45
- num_ok = os.path.exists('emb_num.npy')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  if txt_ok and num_ok:
47
- return np.load('emb_text.npy'), np.load('emb_num.npy')
48
 
 
49
  from sentence_transformers import SentenceTransformer
50
- m = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
51
- texts = DF['text_record'].astype(str).tolist()
52
- Etext = m.encode(texts, batch_size=256, show_progress_bar=True, normalize_embeddings=True)
53
- Etext = np.asarray(Etext, dtype='float32'); np.save('emb_text.npy', Etext)
54
-
55
- X = DF[NUM_COLS].copy()
56
- # FIX: Don't negate acceleration times - lower is better but we handle that in similarity calculation
57
- # The original code had: X['zero_to_100_kmh_s'] = -X['zero_to_100_kmh_s'].astype('float32')
58
- # This is incorrect - we want to preserve actual acceleration times
59
- X['zero_to_100_kmh_s'] = X['zero_to_100_kmh_s'].astype('float32')
60
-
61
- # Handle missing values
62
- X = X.fillna(X.mean())
63
-
64
- Xs = StandardScaler().fit_transform(X.values.astype('float32'))
65
- Enum = Xs.astype('float32'); np.save('emb_num.npy', Enum)
66
  return Etext, Enum
67
 
68
- # ==========================================
69
- # Simplified and data-driven trim handling
70
- # ==========================================
71
-
72
- def get_available_trims(make=None, model=None):
73
- """Get available trims from actual data"""
74
- df = DF.copy()
75
- if make:
76
- df = df[df['make'] == make]
77
- if model:
78
- df = df[df['model'] == model]
79
-
80
- # Get unique trims and clean them up
81
- trims = df['trim'].dropna().astype(str).unique()
82
- # Remove any obviously bad data
83
- trims = [t for t in trims if t not in ['nan', 'None', '', 'null']]
84
- return sorted(trims)
85
-
86
- def get_available_years(make=None, model=None):
87
- """Get available years from actual data"""
88
- df = DF.copy()
89
- if make:
90
- df = df[df['make'] == make]
91
- if model:
92
- df = df[df['model'] == model]
93
-
94
- years = df['year'].dropna().astype(int).unique()
95
- return sorted(years)
96
-
97
- # ==============================
98
- # Dropdowns, filters, formatting
99
- # ==============================
100
-
101
- def models_for(make):
102
- if not make:
103
- return gr.update(choices=[], value=None)
104
- opts = sorted(DF.loc[DF['make'].eq(make), 'model'].dropna().unique().tolist())
105
- return gr.update(choices=opts, value=None)
106
-
107
- def on_model_change(make, model):
108
- trims = get_available_trims(make, model)
109
- years = get_available_years(make, model)
110
- return gr.update(choices=trims, value=None), gr.update(choices=years, value=None)
111
-
112
- def apply_filters(df, body, fuel, y_min, y_max, p_min, p_max, safety, reliab):
113
- out = df.copy()
114
- if body != 'Any':
115
- out = out[out['body_type'] == body]
116
- if fuel != 'Any':
117
- out = out[out['fuel'] == fuel]
118
- out = out[(out['year'] >= y_min) & (out['year'] <= y_max)]
119
- out = out[(out['price_usd'] >= p_min) & (out['price_usd'] <= p_max)]
120
- out = out[(out['safety_rating'] >= safety) & (out['reliability_score'] >= reliab)]
121
  return out
122
 
123
- def fmt_anchor(r):
124
- # Handle NaN values gracefully
125
- trim_display = str(r['trim']) if pd.notna(r['trim']) else ""
126
- acceleration = f"{r['zero_to_100_kmh_s']:.1f}s" if pd.notna(r['zero_to_100_kmh_s']) else "N/A"
127
-
128
- return (f"**{r['make']} {r['model']} {trim_display} {int(r['year'])}** \n"
129
- f"Body: {r['body_type']} • Fuel: {r['fuel']} • Engine: {r['engine_type']} \n"
130
- f"HP: {int(r['horsepower'])} • 0–100: {acceleration} • Price: ${int(r['price_usd']):,} \n"
131
- f"Popularity {int(r['popularity_score'])}/10 • Comfort {int(r['comfort_score'])}/10 • "
132
- f"Reliability {int(r['reliability_score'])}/100 • Safety {int(r['safety_rating'])}★")
133
-
134
- # ==============
135
- # Photo helpers
136
- # ==============
137
- def placeholder_svg_data_uri(title):
138
- # Clean inline SVG placeholder
139
- txt = urllib.parse.quote(title)
140
- svg = f"""<svg xmlns='http://www.w3.org/2000/svg' width='480' height='320'>
141
- <rect width='100%' height='100%' fill='#e8eef7'/>
142
- <text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle'
143
- font-family='Arial' font-size='26' fill='#223'>
144
- {title}
145
- </text>
146
- </svg>"""
147
- return "data:image/svg+xml;utf8," + urllib.parse.quote(svg)
148
-
149
- def build_gallery_html(df_rows):
150
- cards = []
151
- for _, r in df_rows.iterrows():
152
- trim_str = f" {r['trim']}" if pd.notna(r['trim']) and str(r['trim']) != 'nan' else ""
153
- label = f"{r['make']} {r['model']}{trim_str}"
154
-
155
- if 'image_url' in r and isinstance(r['image_url'], str) and r['image_url'].strip():
156
- img_src = r['image_url'].strip()
157
- else:
158
- img_src = placeholder_svg_data_uri(f"{r['make']} {r['model']}")
159
-
160
- cards.append(f"""
161
- <div style="width:240px;margin:6px;border:1px solid #ddd;border-radius:12px;overflow:hidden;background:#fff;">
162
- <img src="{img_src}" style="width:240px;height:160px;object-fit:cover;display:block" />
163
- <div style="padding:8px 10px;font:14px/1.3 Arial,sans-serif;color:#111">{label}</div>
164
- </div>
165
- """)
166
- return f"<div style='display:flex;flex-wrap:wrap'>{''.join(cards)}</div>"
167
-
168
- # ===========================
169
- # Anchor selection & ranking
170
- # ===========================
171
- def anchor_row(make, model, trim, year):
172
- """Find the best matching anchor car from the dataset"""
173
- sub = DF.copy()
174
-
175
- if make:
176
- sub = sub[sub['make'] == make]
177
- if model:
178
- sub = sub[sub['model'] == model]
179
-
180
- def pick_best(df_):
181
- if df_.empty:
182
- return None
183
- # Sort by popularity score (descending) then by year (descending)
184
- return df_.sort_values(['popularity_score', 'year'], ascending=[False, False]).iloc[0]
185
-
186
- # Try exact match first
187
- exact = sub.copy()
188
- if trim and str(trim) != 'nan':
189
- exact = exact[exact['trim'] == trim]
190
- if year:
191
- exact = exact[exact['year'] == year]
192
- if not exact.empty:
193
- return pick_best(exact)
194
-
195
- # Try same year, any trim
196
- if year:
197
- y_only = sub[sub['year'] == year]
198
- if not y_only.empty:
199
- return pick_best(y_only)
200
-
201
- # Try same trim, any year
202
- if trim and str(trim) != 'nan':
203
- t_only = sub[sub['trim'] == trim]
204
- if not t_only.empty:
205
- return pick_best(t_only)
206
-
207
- # Fallback to any match for make/model
208
- return pick_best(sub)
209
-
210
- def calculate_custom_similarity(anchor_idx, candidate_indices, text_emb, num_emb, alpha):
211
- """Calculate similarity with proper handling of acceleration times"""
212
- # Text similarity
213
- text_sim = cosine_similarity(
214
- text_emb[anchor_idx:anchor_idx+1],
215
- text_emb[candidate_indices]
216
- )[0]
217
-
218
- # Numerical similarity - but we need to handle acceleration specially
219
- # For acceleration, lower is better, so we need to invert the similarity logic
220
- anchor_features = num_emb[anchor_idx:anchor_idx+1]
221
- candidate_features = num_emb[candidate_indices]
222
-
223
- # Calculate cosine similarity for numerical features
224
- num_sim = cosine_similarity(anchor_features, candidate_features)[0]
225
-
226
- # Combine similarities
227
- combined_sim = alpha * text_sim + (1 - alpha) * num_sim
228
-
229
- return combined_sim, text_sim, num_sim
230
-
231
- def recommend(make, model, trim, year, topk, alpha,
232
  body, fuel, y_min, y_max, p_min, p_max, safety, reliab,
233
- cross_brand_only=True, exclude_same_model=True):
234
-
235
- # Find anchor car
236
- a = anchor_row(make, model, trim, year)
237
- if a is None:
238
- return "No match found for that combination.", None, "", None
239
-
240
- # Build candidate pool
241
- pool = DF.copy()
 
 
 
 
 
 
 
 
 
 
 
 
242
  if cross_brand_only:
243
- pool = pool[pool['make'] != a['make']]
244
  if exclude_same_model:
245
- pool = pool[~((pool['make'] == a['make']) & (pool['model'] == a['model']))]
246
 
247
- # Apply filters
248
- pool = apply_filters(pool, body, fuel, int(y_min), int(y_max), int(p_min), int(p_max), int(safety), int(reliab))
 
 
 
249
  if pool.empty:
250
- return "No cars match your filters. Try widening the criteria.", None, "", None
251
-
252
- # Calculate similarities
253
- Etext, Enum = ensure_emb()
254
- anchor_idx = int(a.name)
255
- candidate_indices = pool.index.values
256
-
257
- combined_sim, text_sim, num_sim = calculate_custom_similarity(
258
- anchor_idx, candidate_indices, Etext, Enum, alpha
259
- )
260
-
261
- # Rank and select top recommendations
262
- order = np.argsort(-combined_sim)
263
- seen_models = set()
264
- chosen_indices = []
265
-
266
  for j in order:
267
- row_idx = candidate_indices[j]
268
- r = DF.loc[row_idx]
269
- model_key = (r['make'], r['model'])
270
-
271
- if model_key in seen_models:
272
  continue
273
-
274
- seen_models.add(model_key)
275
- chosen_indices.append(row_idx)
276
-
277
- if len(chosen_indices) >= int(topk):
278
  break
 
 
 
 
 
279
 
280
- if not chosen_indices:
281
- return "No recommendations found after applying constraints.", None, "", None
282
-
283
- # Prepare results
284
- results = DF.loc[chosen_indices].copy()
285
-
286
- # Add similarity scores
287
- similarity_scores = {}
288
- for i, idx in enumerate(chosen_indices):
289
- # Find the position in the original candidate list
290
- pos = np.where(candidate_indices == idx)[0][0]
291
- similarity_scores[idx] = round(float(combined_sim[pos]) * 100, 1)
292
-
293
- results['similarity_%'] = results.index.map(lambda x: similarity_scores.get(x, 0.0))
294
-
295
- # Format acceleration properly
296
- results['zero_to_100_display'] = results['zero_to_100_kmh_s'].apply(
297
- lambda x: f"{x:.1f}s" if pd.notna(x) else "N/A"
298
- )
299
-
300
- # Select and order columns for display
301
- display_cols = [
302
- 'name', 'make', 'model', 'trim', 'year', 'body_type', 'fuel', 'engine_type',
303
- 'price_usd', 'horsepower', 'zero_to_100_display', 'popularity_score',
304
- 'comfort_score', 'reliability_score', 'tech_score', 'ownership_cost_score',
305
- 'safety_rating', 'similarity_%'
306
- ]
307
-
308
- # Only include columns that exist in the dataframe
309
- available_cols = [col for col in display_cols if col in results.columns]
310
-
311
- note = (f"α = {float(alpha):.2f} (text ↔ numeric) • Cross-brand only = {cross_brand_only} "
312
- f"• Exclude same model = {exclude_same_model}")
313
-
314
- gallery_html = build_gallery_html(results)
315
-
316
- return fmt_anchor(a), results[available_cols], note, gallery_html
317
-
318
- # ============
319
- # Gradio UI
320
- # ============
321
- def build_ui():
322
- # Get data ranges
323
- y_lo, y_hi = int(DF['year'].min()), int(DF['year'].max())
324
- p_lo, p_hi = int(DF['price_usd'].min()), int(DF['price_usd'].max())
325
-
326
- with gr.Blocks(title="RideSearch - Fixed") as demo:
327
- gr.Markdown("# RideSearch — Cross-brand recommendations (Fixed trim levels & acceleration)")
 
 
 
328
 
329
  with gr.Tab("Pick & Recommend"):
330
  with gr.Row():
331
- mk = gr.Dropdown(
332
- choices=sorted(DF['make'].dropna().unique().tolist()),
333
- label="Make",
334
- value=None
335
- )
336
- md = gr.Dropdown([], label="Model", value=None)
337
- tr = gr.Dropdown([], label="Trim (optional)", value=None)
338
- yr = gr.Dropdown([], label="Year (optional)", value=None)
339
-
340
- # Set up dropdown dependencies
341
  mk.change(models_for, mk, md)
342
- md.change(on_model_change, [mk, md], [tr, yr])
343
 
344
  with gr.Row():
345
- body = gr.Dropdown(
346
- choices=['Any'] + sorted(DF['body_type'].dropna().unique().tolist()),
347
- value='Any',
348
- label='Body Type'
349
- )
350
- fuel = gr.Dropdown(
351
- choices=['Any'] + sorted(DF['fuel'].dropna().unique().tolist()),
352
- value='Any',
353
- label='Fuel Type'
354
- )
355
-
356
  with gr.Row():
357
- y_min = gr.Slider(y_lo, y_hi, value=y_lo, step=1, label='Min Year')
358
- y_max = gr.Slider(y_lo, y_hi, value=y_hi, step=1, label='Max Year')
359
-
360
  with gr.Row():
361
- p_min = gr.Slider(p_lo, p_hi, value=p_lo, step=500, label='Min Price (USD)')
362
- p_max = gr.Slider(p_lo, p_hi, value=min(p_hi, 80000), step=500, label='Max Price (USD)')
363
-
364
  with gr.Row():
365
- safety = gr.Slider(3, 5, value=4, step=1, label='Min Safety Rating ★')
366
- reliab = gr.Slider(55, 99, value=70, step=1, label='Min Reliability Score')
367
-
368
  with gr.Row():
369
- topk = gr.Slider(1, 10, value=5, step=1, label='Number of Recommendations')
370
- alpha = gr.Slider(0, 1, value=0.7, step=0.05, label='α — Text vs Numeric Weight')
371
-
372
  with gr.Row():
373
- cross = gr.Checkbox(label="Cross-brand only", value=True)
374
- xmodel = gr.Checkbox(label="Exclude same model family", value=True)
375
-
376
- go = gr.Button("Get Recommendations", variant="primary")
377
-
378
- # Output components
379
- anchor_display = gr.Markdown(label="Your Selected Car")
380
- results_table = gr.Dataframe(interactive=False, label="Recommendations")
381
- settings_note = gr.Markdown(label="Settings Used")
382
- gallery = gr.HTML(label="Visual Gallery")
383
-
384
- # Connect the button
385
- go.click(
386
- recommend,
387
- inputs=[mk, md, tr, yr, topk, alpha, body, fuel, y_min, y_max, p_min, p_max, safety, reliab, cross, xmodel],
388
- outputs=[anchor_display, results_table, settings_note, gallery]
389
- )
390
-
391
- with gr.Tab("Data Info"):
392
- gr.Markdown("""
393
- ## Fixes Applied:
394
-
395
- 1. **Acceleration Times**: Fixed the incorrect negation of 0-100 km/h times
396
- 2. **Trim Levels**: Simplified trim handling to use actual data from your CSV files
397
- 3. **Data Validation**: Added proper handling of missing/invalid values
398
- 4. **Similarity Calculation**: Improved the similarity algorithm for better recommendations
399
- 5. **UI Improvements**: Better error handling and user feedback
400
-
401
- ## Dataset Overview:
402
- - Cars in database: {:,}
403
- - Years covered: {} - {}
404
- - Price range: ${:,} - ${:,}
405
- - Makes available: {}
406
- """.format(
407
- len(DF),
408
- int(DF['year'].min()), int(DF['year'].max()),
409
- int(DF['price_usd'].min()), int(DF['price_usd'].max()),
410
- len(DF['make'].unique())
411
- ))
412
 
413
- return demo
 
 
414
 
415
- # Initialize and run
416
- demo = build_ui()
 
417
 
418
  if __name__ == "__main__":
 
419
  demo.queue().launch(server_name="0.0.0.0", server_port=7860)
 
1
+ # app.py — RideSearch (real trims, fixed 0–100, cross‑brand, photos)
2
 
3
+ import os, glob, json, math, requests
4
  import numpy as np
5
  import pandas as pd
 
 
6
  import gradio as gr
7
+ from sklearn.preprocessing import StandardScaler
8
+ from sklearn.metrics.pairwise import cosine_similarity
9
+
10
+ # --------- Files / constants ----------
11
+ CSV_MAIN = "RideSearch_dataset.csv"
12
+ CSV_PARTS_GLOB = "RideSearch_part*_small.csv"
13
+ EMB_TEXT_NPY = "emb_text.npy"
14
+ EMB_NUM_NPY = "emb_num.npy"
15
+ EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
16
 
17
+ NUM_COLS = [
18
+ "horsepower","zero_to_100_kmh_s","seats","cargo_liters","price_usd",
19
+ "popularity_score","comfort_score","reliability_score","tech_score",
20
+ "ownership_cost_score","safety_rating"
21
+ ]
22
 
23
+ # --------- Trim database (your long list, deduped) ----------
24
+ REAL_WORLD_TRIMS = {
25
+ # BMW
26
+ ("BMW", "1 Series"): ["116i","118i","120i","125i","M135i"],
27
+ ("BMW", "2 Series"): ["218i","220i","225i","230i","M235i","M240i"],
28
+ ("BMW", "3 Series"): ["318i","320i","325i","330i","330e","335i","340i","M340i","M3"],
29
+ ("BMW", "4 Series"): ["420i","430i","435i","440i","M440i","M4"],
30
+ ("BMW", "5 Series"): ["520i","525i","530i","530e","535i","540i","550i","M550i","M5"],
31
+ ("BMW", "6 Series"): ["630i","640i","650i","M6"],
32
+ ("BMW", "7 Series"): ["730i","740i","750i","760i","M760i"],
33
+ ("BMW", "8 Series"): ["840i","850i","M850i","M8"],
34
+ ("BMW", "X1"): ["sDrive18i","sDrive20i","xDrive20i","xDrive25i"],
35
+ ("BMW", "X2"): ["sDrive18i","sDrive20i","xDrive20i","xDrive25i","M35i"],
36
+ ("BMW", "X3"): ["sDrive20i","xDrive20i","xDrive30i","M40i","X3 M"],
37
+ ("BMW", "X4"): ["xDrive20i","xDrive30i","M40i","X4 M"],
38
+ ("BMW", "X5"): ["sDrive40i","xDrive40i","xDrive45e","M50i","X5 M"],
39
+ ("BMW", "X6"): ["sDrive40i","xDrive40i","M50i","X6 M"],
40
+ ("BMW", "X7"): ["xDrive40i","xDrive50i","M50i"],
41
+ ("BMW", "Z4"): ["sDrive20i","sDrive30i","M40i"],
42
+
43
+ # Audi
44
+ ("Audi", "A1"): ["25 TFSI","30 TFSI","35 TFSI","S1"],
45
+ ("Audi", "A3"): ["30 TFSI","35 TFSI","40 TFSI","45 TFSI e","S3","RS3"],
46
+ ("Audi", "A4"): ["35 TFSI","40 TFSI","45 TFSI","45 TFSI e","S4","RS4"],
47
+ ("Audi", "A5"): ["35 TFSI","40 TFSI","45 TFSI","S5","RS5"],
48
+ ("Audi", "A6"): ["40 TFSI","45 TFSI","50 TFSI","55 TFSI e","S6","RS6"],
49
+ ("Audi", "A7"): ["45 TFSI","50 TFSI","55 TFSI e","S7","RS7"],
50
+ ("Audi", "A8"): ["50 TFSI","55 TFSI","60 TFSI e","S8"],
51
+ ("Audi", "Q2"): ["30 TFSI","35 TFSI","40 TFSI"],
52
+ ("Audi", "Q3"): ["35 TFSI","40 TFSI","45 TFSI e","RS Q3"],
53
+ ("Audi", "Q5"): ["40 TFSI","45 TFSI e","SQ5"],
54
+ ("Audi", "Q7"): ["45 TFSI","50 TDI","55 TFSI e","SQ7"],
55
+ ("Audi", "Q8"): ["50 TDI","55 TFSI","SQ8","RS Q8"],
56
+ ("Audi", "TT"): ["40 TFSI","45 TFSI","TTS","TT RS"],
57
+ ("Audi", "R8"): ["V10","V10 Performance","V10 Plus"],
58
+
59
+ # Mercedes-Benz
60
+ ("Mercedes-Benz", "A-Class"): ["A180","A200","A220","A250","A250e","AMG A35","AMG A45"],
61
+ ("Mercedes-Benz", "B-Class"): ["B180","B200","B220","B250e"],
62
+ ("Mercedes-Benz", "C-Class"): ["C180","C200","C220d","C250","C300","C350e","AMG C43","AMG C63"],
63
+ ("Mercedes-Benz", "E-Class"): ["E200","E220d","E300","E350","E400","E450","AMG E53","AMG E63"],
64
+ ("Mercedes-Benz", "S-Class"): ["S450","S500","S580","AMG S63","AMG S65"],
65
+ ("Mercedes-Benz", "CLA"): ["CLA180","CLA200","CLA220","CLA250","AMG CLA35","AMG CLA45"],
66
+ ("Mercedes-Benz", "CLS"): ["CLS350","CLS450","AMG CLS53","AMG CLS63"],
67
+ ("Mercedes-Benz", "GLA"): ["GLA180","GLA200","GLA220","GLA250","AMG GLA35","AMG GLA45"],
68
+ ("Mercedes-Benz", "GLB"): ["GLB200","GLB220","GLB250","AMG GLB35"],
69
+ ("Mercedes-Benz", "GLC"): ["GLC200","GLC220d","GLC300","GLC350e","AMG GLC43","AMG GLC63"],
70
+ ("Mercedes-Benz", "GLE"): ["GLE350","GLE450","GLE580","AMG GLE53","AMG GLE63"],
71
+ ("Mercedes-Benz", "GLS"): ["GLS450","GLS580","AMG GLS63"],
72
+ ("Mercedes-Benz", "G-Class"): ["G500","G550","AMG G63"],
73
+
74
+ # Toyota (sedans/SUVs)
75
+ ("Toyota", "Corolla"): ["L","LE","XLE","SE","XSE","GR Corolla"],
76
+ ("Toyota", "Camry"): ["LE","SE","XLE","XSE","TRD"],
77
+ ("Toyota", "Avalon"): ["XLE","XSE","Limited","TRD"],
78
+ ("Toyota", "Prius"): ["LE","XLE","Limited","Prime"],
79
+ ("Toyota", "RAV4"): ["LE","XLE","XLE Premium","Adventure","TRD Off-Road","Limited","Prime"],
80
+ ("Toyota", "Highlander"): ["L","LE","XLE","Limited","Platinum","Hybrid"],
81
+ ("Toyota", "4Runner"): ["SR5","TRD Off-Road","TRD Pro","Limited"],
82
+ ("Toyota", "Tacoma"): ["SR","SR5","TRD Sport","TRD Off-Road","TRD Pro","Limited"],
83
+ ("Toyota", "Tundra"): ["SR","SR5","Limited","Platinum","1794","TRD Pro"],
84
+ ("Toyota", "Land Cruiser"): ["Base","Heritage Edition"],
85
+ ("Toyota", "Supra"): ["2.0","3.0","3.0 Premium"],
86
+ ("Toyota", "Yaris"): ["L","LE","XLE","GRMN"],
87
+ ("Toyota", "C-HR"): ["LE","XLE","Nightshade","Limited"],
88
+ ("Toyota", "Sequoia"): ["SR5","Limited","Platinum","TRD Pro","Capstone"],
89
+ ("Toyota", "Sienna"): ["LE","XLE","XSE","Limited","Platinum"],
90
+
91
+ # Honda
92
+ ("Honda", "Civic"): ["LX","Sport","EX","EX-L","Sport Touring","Touring","Si","Type R"],
93
+ ("Honda", "Accord"): ["LX","Sport","EX","EX-L","Touring","Sport-L"],
94
+ ("Honda", "CR-V"): ["LX","EX","EX-L","Touring","Hybrid"],
95
+ ("Honda", "HR-V"): ["LX","Sport","EX","EX-L"],
96
+ ("Honda", "Pilot"): ["LX","EX","EX-L","Touring","Elite","TrailSport"],
97
+ ("Honda", "Passport"): ["Sport","EX-L","Touring","Elite","TrailSport"],
98
+ ("Honda", "Ridgeline"): ["Sport","RTL","RTL-E","Black Edition"],
99
+ ("Honda", "Insight"): ["LX","EX","Touring"],
100
+ ("Honda", "Fit / Jazz"): ["LX","Sport","EX","EX-L"],
101
+ ("Honda", "Odyssey"): ["EX","EX-L","Touring","Elite"],
102
+
103
+ # Volkswagen
104
+ ("Volkswagen", "Golf"): ["S","SE","SEL","Autobahn","GTI","R"],
105
+ ("Volkswagen", "Jetta"): ["S","SE","SEL","SEL Premium","GLI"],
106
+ ("Volkswagen", "Passat"): ["S","SE","SEL Premium","R-Line"],
107
+ ("Volkswagen", "Tiguan"): ["S","SE","SEL","SEL Premium","R-Line"],
108
+ ("Volkswagen", "Atlas"): ["S","SE","SEL","SEL Premium","Cross Sport"],
109
+ ("Volkswagen", "Arteon"): ["SE","SEL","SEL Premium","R-Line"],
110
+ ("Volkswagen", "ID.4"): ["Pro","Pro S","1st Edition"],
111
+ ("Volkswagen", "Tiguan (alt)"): ["S","SE","SEL","R-Line"],
112
+
113
+ # Nissan
114
+ ("Nissan", "Sentra"): ["S","SV","SR"],
115
+ ("Nissan", "Altima"): ["S","SV","SL","SR","Platinum"],
116
+ ("Nissan", "Maxima"): ["S","SV","SL","SR","Platinum"],
117
+ ("Nissan", "Versa"): ["S","SV","SR"],
118
+ ("Nissan", "Rogue"): ["S","SV","SL","Platinum"],
119
+ ("Nissan", "Murano"): ["S","SV","SL","Platinum"],
120
+ ("Nissan", "Pathfinder"): ["S","SV","SL","Platinum"],
121
+ ("Nissan", "Armada"): ["SV","SL","Platinum"],
122
+ ("Nissan", "Frontier"): ["S","SV","PRO-4X"],
123
+ ("Nissan", "Titan"): ["S","SV","PRO-4X","Platinum Reserve"],
124
+ ("Nissan", "Z"): ["Sport","Performance"],
125
+ ("Nissan", "GT-R"): ["Premium","NISMO","Track Edition"],
126
+ ("Nissan", "Qashqai / Rogue Sport"): ["S","SV","SL"],
127
+ ("Nissan", "X-Trail"): ["Visia","Acenta","N-Connecta","Tekna"],
128
+
129
+ # Hyundai
130
+ ("Hyundai", "Elantra"): ["SE","SEL","Limited","N Line","N"],
131
+ ("Hyundai", "Sonata"): ["SE","SEL","Limited","N Line"],
132
+ ("Hyundai", "Accent"): ["SE","SEL"],
133
+ ("Hyundai", "Tucson"): ["SE","SEL","Limited","N Line"],
134
+ ("Hyundai", "Santa Fe"): ["SE","SEL","Limited","Calligraphy"],
135
+ ("Hyundai", "Palisade"): ["SE","SEL","Limited","Calligraphy"],
136
+ ("Hyundai", "Kona"): ["SE","SEL","Limited","N Line","N"],
137
+ ("Hyundai", "Venue"): ["SE","SEL"],
138
+ ("Hyundai", "Ioniq 5"): ["SE","SEL","Limited"],
139
+ ("Hyundai", "Ioniq 6"): ["SE","SEL","Limited"],
140
+ ("Hyundai", "Santa Cruz"): ["SE","SEL","Night","Limited"],
141
+
142
+ # Kia
143
+ ("Kia", "Rio"): ["LX","S"],
144
+ ("Kia", "Forte"): ["LX","S","EX","GT-Line","GT"],
145
+ ("Kia", "K5"): ["LX","S","EX","GT-Line"],
146
+ ("Kia", "Stinger"): ["GT-Line","GT1","GT2"],
147
+ ("Kia", "Soul"): ["LX","S","EX","GT-Line"],
148
+ ("Kia", "Seltos"): ["LX","S","EX","SX Turbo"],
149
+ ("Kia", "Sportage"): ["LX","S","EX","SX Turbo"],
150
+ ("Kia", "Sorento"): ["LX","S","EX","SX","SX Turbo"],
151
+ ("Kia", "Telluride"): ["LX","S","EX","SX"],
152
+ ("Kia", "Carnival"): ["LX","S","EX","SX"],
153
+ ("Kia", "EV6"): ["Light","Wind","GT-Line"],
154
+ ("Kia", "Niro"): ["LX","EX","SX Touring"],
155
+ ("Kia", "EV9"): ["Light","Wind","Land","GT-Line"],
156
+
157
+ # Ford
158
+ ("Ford", "Fiesta"): ["S","SE","Titanium","ST"],
159
+ ("Ford", "Focus"): ["S","SE","SEL","Titanium","ST","RS"],
160
+ ("Ford", "Fusion"): ["S","SE","SEL","Titanium","Sport"],
161
+ ("Ford", "Mustang"): ["EcoBoost","GT","Mach 1","Shelby GT350","Shelby GT500"],
162
+ ("Ford", "Escape"): ["S","SE","SEL","Titanium"],
163
+ ("Ford", "Edge"): ["SE","SEL","Titanium","ST"],
164
+ ("Ford", "Explorer"): ["Base","XLT","Limited","King Ranch","Platinum","ST"],
165
+ ("Ford", "Expedition"): ["XLT","Limited","King Ranch","Platinum"],
166
+ ("Ford", "F-150"): ["Regular Cab","SuperCab","SuperCrew","Raptor","Lightning"],
167
+ ("Ford", "Ranger"): ["XL","XLT","Lariat"],
168
+ ("Ford", "Bronco"): ["Base","Big Bend","Black Diamond","Outer Banks","Badlands","Wildtrak","Raptor"],
169
+ ("Ford", "Maverick"): ["XL","XLT","Lariat","Tremor"],
170
+ ("Ford", "Bronco Sport"): ["Base","Big Bend","Outer Banks","Badlands"],
171
+
172
+ # Chevrolet
173
+ ("Chevrolet", "Spark"): ["LS","LT","Premier"],
174
+ ("Chevrolet", "Sonic"): ["LS","LT","Premier"],
175
+ ("Chevrolet", "Cruze"): ["L","LS","LT","Premier"],
176
+ ("Chevrolet", "Malibu"): ["L","LS","LT","Premier"],
177
+ ("Chevrolet", "Impala"): ["LS","LT","Premier"],
178
+ ("Chevrolet", "Camaro"): ["1LS","1LT","2LT","1SS","2SS","ZL1"],
179
+ ("Chevrolet", "Corvette"): ["1LT","2LT","3LT","Z06","ZR1"],
180
+ ("Chevrolet", "Trax"): ["L","LS","LT","Premier"],
181
+ ("Chevrolet", "Equinox"): ["L","LS","LT","Premier"],
182
+ ("Chevrolet", "Traverse"): ["L","LS","LT","Premier","High Country"],
183
+ ("Chevrolet", "Tahoe"): ["LS","LT","RST","Premier","High Country"],
184
+ ("Chevrolet", "Suburban"): ["LS","LT","RST","Premier","High Country"],
185
+ ("Chevrolet", "Blazer"): ["LT","RS","Premier"],
186
+ ("Chevrolet", "Trailblazer"): ["LS","LT","ACTIV","RS"],
187
+ ("Chevrolet", "Bolt EV"): ["1LT","2LT"],
188
+ ("Chevrolet", "Silverado 1500"): ["Work Truck","Custom","LT","RST","LTZ","High Country"],
189
+
190
+ # Lexus
191
+ ("Lexus", "IS"): ["300","350","500 F SPORT Performance"],
192
+ ("Lexus", "ES"): ["250","300h","350"],
193
+ ("Lexus", "GS"): ["300","350","450h","F"],
194
+ ("Lexus", "LS"): ["500","500h"],
195
+ ("Lexus", "LC"): ["500","500h"],
196
+ ("Lexus", "RC"): ["300","350","F"],
197
+ ("Lexus", "UX"): ["200","250h"],
198
+ ("Lexus", "NX"): ["250","350","350h","450h+"],
199
+ ("Lexus", "RX"): ["350","350h","500h F SPORT Performance"],
200
+ ("Lexus", "GX"): ["460"],
201
+ ("Lexus", "LX"): ["570","600"],
202
+
203
+ # Infiniti
204
+ ("Infiniti", "Q50"): ["Pure","Luxe","Sensory","Red Sport 400"],
205
+ ("Infiniti", "Q60"): ["Pure","Luxe","Sensory","Red Sport 400"],
206
+ ("Infiniti", "Q70"): ["Base","Sport"],
207
+ ("Infiniti", "QX50"): ["Pure","Luxe","Sensory","Autograph"],
208
+ ("Infiniti", "QX55"): ["Luxe","Essential","Sensory"],
209
+ ("Infiniti", "QX60"): ["Pure","Luxe","Sensory","Autograph"],
210
+ ("Infiniti", "QX80"): ["Pure","Luxe","Sensory","Autograph"],
211
+
212
+ # Acura
213
+ ("Acura", "ILX"): ["Base","Premium","A-Spec"],
214
+ ("Acura", "TLX"): ["Base","Technology","A-Spec","Advance","Type S"],
215
+ ("Acura", "RLX"): ["Base","Technology","Advance"],
216
+ ("Acura", "NSX"): ["Base","Type S"],
217
+ ("Acura", "RDX"): ["Base","Technology","A-Spec","Advance"],
218
+ ("Acura", "MDX"): ["Base","Technology","A-Spec","Advance","Type S"],
219
+
220
+ # Cadillac
221
+ ("Cadillac", "ATS"): ["Base","Luxury","Premium Luxury","V-Sport","V"],
222
+ ("Cadillac", "CTS"): ["Base","Luxury","Premium Luxury","V-Sport","V"],
223
+ ("Cadillac", "CT4"): ["Luxury","Premium Luxury","Sport","V-Series","V-Series Blackwing"],
224
+ ("Cadillac", "CT5"): ["Luxury","Premium Luxury","Sport","V-Series","V-Series Blackwing"],
225
+ ("Cadillac", "XTS"): ["Base","Luxury","Premium Luxury","Platinum"],
226
+ ("Cadillac", "XT4"): ["Luxury","Premium Luxury","Sport"],
227
+ ("Cadillac", "XT5"): ["Luxury","Premium Luxury","Sport"],
228
+ ("Cadillac", "XT6"): ["Luxury","Premium Luxury","Sport"],
229
+ ("Cadillac", "Escalade"): ["Luxury","Premium Luxury","Sport","V-Series"],
230
+
231
+ # Lincoln
232
+ ("Lincoln", "MKZ"): ["Premiere","Select","Reserve"],
233
+ ("Lincoln", "Continental"): ["Premiere","Select","Reserve","Coach Door Edition"],
234
+ ("Lincoln", "Corsair"): ["Base","Reserve"],
235
+ ("Lincoln", "Nautilus"): ["Base","Reserve"],
236
+ ("Lincoln", "Aviator"): ["Premiere","Reserve","Grand Touring","Black Label"],
237
+ ("Lincoln", "Navigator"): ["Premiere","Select","Reserve","Black Label"],
238
+
239
+ # Buick
240
+ ("Buick", "Verano"): ["Base","Convenience","Leather","Premium"],
241
+ ("Buick", "Regal"): ["1SV","Preferred","Essence","GS"],
242
+ ("Buick", "LaCrosse"): ["Base","Preferred","Essence","Premium","Avenir"],
243
+ ("Buick", "Encore"): ["Base","Convenience","Leather","Premium"],
244
+ ("Buick", "Envision"): ["Preferred","Essence","Premium","Avenir"],
245
+ ("Buick", "Enclave"): ["Base","Essence","Premium","Avenir"],
246
+
247
+ # Genesis
248
+ ("Genesis", "G70"): ["2.0T","2.0T Advanced","3.3T Sport","3.3T Sport Prestige"],
249
+ ("Genesis", "G80"): ["2.5T","2.5T Advanced","3.5T Sport","3.5T Sport Prestige"],
250
+ ("Genesis", "G90"): ["3.3T Premium","3.3T Prestige","5.0 Ultimate"],
251
+ ("Genesis", "GV70"): ["2.5T","2.5T Advanced","3.5T Sport","3.5T Sport Prestige"],
252
+ ("Genesis", "GV80"): ["2.5T","2.5T Advanced","3.5T","3.5T Prestige"],
253
+
254
+ # Tesla
255
+ ("Tesla", "Model S"): ["Standard Range","Long Range","Plaid"],
256
+ ("Tesla", "Model 3"): ["Standard Range Plus","Long Range","Performance"],
257
+ ("Tesla", "Model X"): ["Standard Range","Long Range","Plaid"],
258
+ ("Tesla", "Model Y"): ["Standard Range","Long Range","Performance"],
259
+
260
+ # Porsche
261
+ ("Porsche", "911"): ["Carrera","Carrera S","Carrera 4","Carrera 4S","Turbo","Turbo S","GT3","GT3 RS","GT2 RS"],
262
+ ("Porsche", "Boxster"): ["Base","S","GTS","Spyder"],
263
+ ("Porsche", "Cayman"): ["Base","S","GTS","GT4"],
264
+ ("Porsche", "Panamera"): ["Base","4","S","4S","GTS","Turbo","Turbo S"],
265
+ ("Porsche", "Macan"): ["Base","S","GTS","Turbo"],
266
+ ("Porsche", "Cayenne"): ["Base","S","GTS","Turbo","Turbo S","E-Hybrid"],
267
+ ("Porsche", "Taycan"): ["Base","4S","Turbo","Turbo S"],
268
+
269
+ # Jaguar
270
+ ("Jaguar", "XE"): ["Base","Premium","Prestige","R-Sport","S"],
271
+ ("Jaguar", "XF"): ["Premium","Prestige","R-Sport","S"],
272
+ ("Jaguar", "XJ"): ["Premium Luxury","Portfolio","Autobiography","XJR575"],
273
+ ("Jaguar", "F-TYPE"): ["Base","Premium","R-Dynamic","R","SVR"],
274
+ ("Jaguar", "E-PACE"): ["Base","S","SE","HSE","R-Dynamic"],
275
+ ("Jaguar", "F-PACE"): ["Premium","Prestige","R-Sport","S","SVR"],
276
+ ("Jaguar", "I-PACE"): ["S","SE","HSE","First Edition"],
277
+
278
+ # Land Rover
279
+ ("Land Rover", "Range Rover Evoque"): ["S","SE","HSE","HSE Dynamic","Autobiography"],
280
+ ("Land Rover", "Range Rover Velar"): ["S","SE","HSE","R-Dynamic","P380","P550"],
281
+ ("Land Rover", "Range Rover Sport"): ["HSE","HSE Dynamic","Autobiography","SVR"],
282
+ ("Land Rover", "Range Rover"): ["Base","HSE","Autobiography","SV","SVAutobiography"],
283
+ ("Land Rover", "Discovery Sport"): ["S","SE","HSE","HSE Luxury"],
284
+ ("Land Rover", "Discovery"): ["S","SE","HSE","HSE Luxury"],
285
+ ("Land Rover", "Defender"): ["90","110","130","X","X-Dynamic","First Edition"],
286
+
287
+ # Volvo
288
+ ("Volvo", "S60"): ["T5 Momentum","T5 R-Design","T6 R-Design","T8 Polestar"],
289
+ ("Volvo", "S90"): ["T5 Momentum","T6 Momentum","T6 R-Design","T8 Inscription"],
290
+ ("Volvo", "V60"): ["T5 Momentum","T5 R-Design","T6 R-Design","T8 Polestar"],
291
+ ("Volvo", "V90"): ["T5 Momentum","T6 Momentum","T6 R-Design","T8 Inscription"],
292
+ ("Volvo", "XC40"): ["T4 Momentum","T5 Momentum","T5 R-Design","Recharge"],
293
+ ("Volvo", "XC60"): ["Core","Plus","Ultimate","Polestar Engineered"],
294
+ ("Volvo", "XC90"): ["Momentum","R-Design","Inscription","Recharge"],
295
+
296
+ # MINI
297
+ ("Mini", "Cooper"): ["Base","Classic","Signature","Iconic","John Cooper Works"],
298
+ ("Mini", "Countryman"): ["Classic","Signature","Iconic","John Cooper Works"],
299
+ ("Mini", "Clubman"): ["Classic","Signature","Iconic","John Cooper Works"],
300
+
301
+ # Mazda
302
+ ("Mazda", "Mazda3"): ["Base","Select","Preferred","Premium","Turbo"],
303
+ ("Mazda", "Mazda6"): ["Sport","Touring","Grand Touring","Grand Touring Reserve","Signature"],
304
+ ("Mazda", "CX-3"): ["Sport","Touring","Grand Touring"],
305
+ ("Mazda", "CX-30"): ["S","Select","Preferred","Premium","Turbo","Turbo Premium Plus"],
306
+ ("Mazda", "CX-5"): ["S","Select","Preferred","Premium","Turbo","Turbo Signature"],
307
+ ("Mazda", "CX-50"): ["2.5 S","2.5 S Select","2.5 S Preferred","2.5 Turbo","2.5 Turbo Premium"],
308
+ ("Mazda", "CX-9"): ["Sport","Touring","Grand Touring","Signature","Carbon Edition"],
309
+ ("Mazda", "MX-5 Miata"): ["Sport","Club","Grand Touring"],
310
+ ("Mazda", "MX-30"): ["EV","EV Premium Plus"],
311
+
312
+ # Peugeot
313
+ ("Peugeot", "208"): ["Active","Allure","GT"],
314
+ ("Peugeot", "2008"): ["Active","Allure","GT"],
315
+ ("Peugeot", "308"): ["Active","Allure","GT"],
316
+ ("Peugeot", "3008"): ["Active","Allure","GT","GT Pack"],
317
+ ("Peugeot", "5008"): ["Active","Allure","GT"],
318
+ ("Peugeot", "508"): ["Active","Allure","GT","PSE"],
319
+
320
+ # Renault
321
+ ("Renault", "Clio"): ["Play","Iconic","S Edition","RS Line"],
322
+ ("Renault", "Megane"): ["Play","Iconic","RS Line","RS Trophy"],
323
+ ("Renault", "Captur"): ["Play","Iconic","S Edition","RS Line"],
324
+ ("Renault", "Kadjar"): ["Play","Iconic","S Edition","GT Line"],
325
+ ("Renault", "Arkana"): ["Iconic","S Edition","RS Line"],
326
+ ("Renault", "Austral"): ["Equilibre","Techno","Esprit Alpine"],
327
+
328
+ # Škoda / Skoda
329
+ ("Skoda", "Fabia"): ["S","SE","SE L","Monte Carlo"],
330
+ ("Skoda", "Octavia"): ["S","SE","SE L","SportLine","vRS"],
331
+ ("Skoda", "Superb"): ["SE","SE L","SportLine","L&K"],
332
+ ("Skoda", "Karoq"): ["SE Drive","SE L","SportLine"],
333
+ ("Skoda", "Kodiaq"): ["SE","SE L","SportLine","vRS"],
334
+
335
+ # SEAT
336
+ ("Seat", "Ibiza"): ["Reference","Style","Xcellence","FR"],
337
+ ("Seat", "Leon"): ["Reference","Style","Xcellence","FR","Cupra"],
338
+ ("Seat", "Arona"): ["Reference","Style","Xcellence","FR"],
339
+ ("Seat", "Ateca"): ["Reference","Style","Xcellence","FR"],
340
+ ("Seat", "Tarraco"): ["SE","SE Technology","Xcellence","FR"],
341
+
342
+ # Alfa Romeo
343
+ ("Alfa Romeo", "Giulia"): ["Sprint","Ti","Veloce","Quadrifoglio"],
344
+ ("Alfa Romeo", "Stelvio"): ["Sprint","Ti","Veloce","Quadrifoglio"],
345
+ ("Alfa Romeo", "Tonale"): ["Ti","Veloce","Edizione Speciale"],
346
+
347
+ # Citroën
348
+ ("Citroen", "C3"): ["Live","Feel","Shine"],
349
+ ("Citroen", "C4"): ["Live","Feel","Shine"],
350
+ ("Citroen", "C5 Aircross"): ["Feel","Shine","C-Series","Max"],
351
+
352
+ # Dodge
353
+ ("Dodge", "Charger"): ["SXT","GT","R/T","Scat Pack","SRT Hellcat"],
354
+ ("Dodge", "Challenger"): ["SXT","GT","R/T","Scat Pack","SRT Hellcat"],
355
+ ("Dodge", "Durango"): ["SXT","GT","R/T","Citadel","SRT"],
356
+
357
+ # Ram
358
+ ("Ram", "1500"): ["Tradesman","Big Horn","Laramie","Rebel","Limited"],
359
+
360
+ # GMC
361
+ ("GMC", "Terrain"): ["SLE","SLT","AT4","Denali"],
362
+ ("GMC", "Acadia"): ["SLE","SLT","AT4","Denali"],
363
+ ("GMC", "Yukon"): ["SLE","SLT","AT4","Denali"],
364
+ ("GMC", "Sierra 1500"): ["Pro","SLE","Elevation","SLT","AT4","Denali"],
365
+
366
+ # Chrysler
367
+ ("Chrysler", "300"): ["Touring","Touring L","S"],
368
+ ("Chrysler", "Pacifica"): ["Touring","Touring L","Limited","Pinnacle"],
369
+ }
370
+
371
+ # --------- Load + unify ----------
372
  def load_df():
373
+ if os.path.exists(CSV_MAIN):
374
+ return pd.read_csv(CSV_MAIN)
375
+ parts = sorted(glob.glob(CSV_PARTS_GLOB))
376
  if not parts:
377
  raise FileNotFoundError("Upload RideSearch_dataset.csv OR the 10 parts RideSearch_part*_small.csv.")
378
  df = pd.concat([pd.read_csv(p) for p in parts], ignore_index=True)
379
+ df.to_csv(CSV_MAIN, index=False)
380
  return df
381
 
382
+ def unify_columns(df: pd.DataFrame) -> pd.DataFrame:
383
+ df = df.copy()
384
+ # create missing columns
385
+ for c in ["make","model","trim_display","year","body_type","fuel","engine_type","name"]:
386
+ if c not in df.columns:
387
+ df[c] = ""
388
+ if df["trim_display"].isna().all() and "trim" in df.columns:
389
+ df["trim_display"] = df["trim"].fillna("")
390
+
391
+ # alias numeric score names
392
+ if "popularity" in df.columns and "popularity_score" not in df.columns:
393
+ df["popularity_score"] = df["popularity"]
394
+ if "comfort" in df.columns and "comfort_score" not in df.columns:
395
+ df["comfort_score"] = df["comfort"]
396
+ if "reliability" in df.columns and "reliability_score" not in df.columns:
397
+ df["reliability_score"] = df["reliability"]
398
+ if "safety" in df.columns and "safety_rating" not in df.columns:
399
+ df["safety_rating"] = df["safety"]
400
+
401
+ for c in NUM_COLS:
402
+ if c not in df.columns: df[c] = np.nan
403
+ df[c] = pd.to_numeric(df[c], errors="coerce")
404
+
405
+ # build text_record if missing
406
+ if "text_record" not in df.columns:
407
+ cols = ["make","model","trim_display","body_type","fuel","engine_type"]
408
+ df["text_record"] = df[cols].fillna("").astype(str).agg(" ".join, axis=1)
409
 
410
+ return df
411
 
412
+ # --------- Fix 0–100 if broken ----------
413
+ def suspicious_zero_to_100(series: pd.Series) -> bool:
414
+ s = series.dropna()
415
+ if len(s) < 30: # need some mass to judge
416
+ return False
417
+ return (s.nunique() <= max(2, int(0.03*len(s)))) or (s.std() < 0.18)
418
+
419
+ def estimate_0_100(row):
420
+ # Heuristic depending on hp, body, fuel, trim hints. Clamped 2.8..14.5
421
+ try: hp = float(row.get("horsepower", 150) or 150)
422
+ except Exception: hp = 150.0
423
+ body = str(row.get("body_type","")).lower()
424
+ fuel = str(row.get("fuel","")).lower()
425
+ trim = f"{row.get('model','')} {row.get('trim_display','')}".lower()
426
+
427
+ base = 26.0 - 3.2*np.log(max(hp, 60.0)) # ~10s @100hp; ~6s @300hp
428
+ if any(k in body for k in ["suv","crossover","pickup","truck","van"]): base += 0.7
429
+ if any(k in body for k in ["coupe","roadster","hatch"]): base -= 0.3
430
+ if "electric" in fuel or "ev" in fuel: base -= 0.8
431
+ if "hybrid" in fuel: base -= 0.3
432
+ if "diesel" in fuel: base += 0.2
433
+ if any(x in trim for x in [" m ","amg","rs","type r","sti","gts","gt3","hellcat","svr","cupra","john cooper works"]):
434
+ base -= 0.6
435
+ return float(np.clip(round(base,2), 2.8, 14.5))
436
+
437
+ # --------- Embeddings (text + numeric) ----------
438
+ def ensure_embeddings(df):
439
+ txt_ok = os.path.exists(EMB_TEXT_NPY)
440
+ num_ok = os.path.exists(EMB_NUM_NPY)
441
  if txt_ok and num_ok:
442
+ return np.load(EMB_TEXT_NPY), np.load(EMB_NUM_NPY)
443
 
444
+ # Text embeddings
445
  from sentence_transformers import SentenceTransformer
446
+ m = SentenceTransformer(EMBED_MODEL)
447
+ texts = df["text_record"].astype(str).tolist()
448
+ Etext = m.encode(texts, batch_size=256, show_progress_bar=False, normalize_embeddings=True)
449
+ Etext = np.asarray(Etext, dtype="float32"); np.save(EMB_TEXT_NPY, Etext)
450
+
451
+ # Numeric embeddings
452
+ X = df[NUM_COLS].copy()
453
+ X["zero_to_100_kmh_s"] = X["zero_to_100_kmh_s"].astype("float32")
454
+ X = X.fillna(X.mean(numeric_only=True))
455
+ Xs = StandardScaler().fit_transform(X.values.astype("float32"))
456
+ Enum = Xs.astype("float32"); np.save(EMB_NUM_NPY, Enum)
 
 
 
 
 
457
  return Etext, Enum
458
 
459
+ # --------- Trims (curated + learned from CSV) ----------
460
+ def learned_trims_from_csv(df):
461
+ out = {}
462
+ grp = df.groupby(["make","model"])
463
+ for (mk, md), sub in grp:
464
+ vals = sub["trim_display"].dropna().astype(str).value_counts().index.tolist()
465
+ vals = [v for v in vals if v.lower() not in ("", "nan", "none")]
466
+ if vals: out[(mk, md)] = vals[:20]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  return out
468
 
469
+ def trims_for(make, model, learned):
470
+ key = (make, model)
471
+ if key in REAL_WORLD_TRIMS: return REAL_WORLD_TRIMS[key]
472
+ if key in learned: return learned[key]
473
+ return ["Base"]
474
+
475
+ # --------- Images via Wikipedia ----------
476
+ def wiki_image(make, model, year=None):
477
+ q = f"{year} {make} {model}" if year else f"{make} {model}"
478
+ try:
479
+ r = requests.get(
480
+ "https://en.wikipedia.org/w/api.php",
481
+ params={
482
+ "action":"query","format":"json","prop":"pageimages",
483
+ "piprop":"thumbnail","pithumbsize":600,
484
+ "generator":"search","gsrsearch":q,"gsrlimit":1,"gsrnamespace":0
485
+ },
486
+ timeout=6
487
+ ).json()
488
+ pages = r.get("query",{}).get("pages",{})
489
+ if pages:
490
+ page = next(iter(pages.values()))
491
+ return page.get("thumbnail",{}).get("source")
492
+ except Exception:
493
+ pass
494
+ return None
495
+
496
+ # --------- Recommend ----------
497
+ def recommend(df, Etext, Enum, make, model, trim, year, topk, alpha,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  body, fuel, y_min, y_max, p_min, p_max, safety, reliab,
499
+ cross_brand_only=True, exclude_same_model=True, unique_brand=True):
500
+
501
+ if not make or not model:
502
+ return "Pick a make & model first.", None, "", []
503
+
504
+ # anchor candidates
505
+ sub = df[(df["make"]==make) & (df["model"]==model)].copy()
506
+ if trim:
507
+ sub = sub[sub["trim_display"].astype(str).str.lower() == str(trim).lower()]
508
+ if year:
509
+ sub = sub[pd.to_numeric(sub["year"], errors="coerce")==int(year)]
510
+ if sub.empty:
511
+ sub = df[(df["make"]==make) & (df["model"]==model)].copy()
512
+ if sub.empty:
513
+ return "No such make/model in dataset.", None, "", []
514
+
515
+ a = sub.sort_values("popularity_score", ascending=False).iloc[0]
516
+ a_idx = int(a.name)
517
+
518
+ # candidate pool
519
+ pool = df.copy()
520
  if cross_brand_only:
521
+ pool = pool[pool["make"] != a["make"]]
522
  if exclude_same_model:
523
+ pool = pool[~((pool["make"] == a["make"]) & (pool["model"] == a["model"]))]
524
 
525
+ if body and body != "Any": pool = pool[pool["body_type"] == body]
526
+ if fuel and fuel != "Any": pool = pool[pool["fuel"] == fuel]
527
+ pool = pool[(pool["year"] >= y_min) & (pool["year"] <= y_max)]
528
+ pool = pool[(pool["price_usd"] >= p_min) & (pool["price_usd"] <= p_max)]
529
+ pool = pool[(pool["safety_rating"] >= safety) & (pool["reliability_score"] >= reliab)]
530
  if pool.empty:
531
+ return "No cars after your filters. Loosen price/year/safety.", None, "", []
532
+
533
+ cand_idx = pool.index.values
534
+ st = cosine_similarity(Etext[cand_idx], Etext[a_idx].reshape(1,-1)).ravel()
535
+ sn = cosine_similarity(Enum[cand_idx], Enum[a_idx].reshape(1,-1)).ravel()
536
+ s = float(alpha)*st + (1-float(alpha))*sn
537
+
538
+ order = np.argsort(-s)
539
+ chosen, seen = [], set()
 
 
 
 
 
 
 
540
  for j in order:
541
+ k = cand_idx[j]
542
+ brand = str(df.loc[k,"make"]).lower()
543
+ if unique_brand and brand in seen:
 
 
544
  continue
545
+ seen.add(brand)
546
+ chosen.append(k)
547
+ if len(chosen) >= int(topk):
 
 
548
  break
549
+ if not chosen:
550
+ return "No recommendations after constraints.", None, "", []
551
+
552
+ out = df.loc[chosen].copy()
553
+ out["similarity_%"] = [round(float(s[np.where(cand_idx==k)[0][0]])*100,1) for k in chosen]
554
 
555
+ cols = ["name","make","model","trim_display","year","body_type","fuel","engine_type",
556
+ "price_usd","horsepower","zero_to_100_kmh_s","popularity_score","comfort_score",
557
+ "reliability_score","tech_score","ownership_cost_score","safety_rating","similarity_%"]
558
+ table = out[[c for c in cols if c in out.columns]].reset_index(drop=True)
559
+
560
+ disp_trim = str(a.get("trim_display","")).strip()
561
+ anchor_md = (f"**{a['make']} {a['model']} {disp_trim} {int(a['year']) if pd.notna(a['year']) else ''}** \n"
562
+ f"Body: {a.get('body_type','')} • Fuel: {a.get('fuel','')} • Engine: {a.get('engine_type','')} \n"
563
+ f"HP: {int(a['horsepower']) if pd.notna(a['horsepower']) else '—'} • 0–100: {a.get('zero_to_100_kmh_s','—')}s "
564
+ f"• Price: ${int(a['price_usd']) if pd.notna(a['price_usd']) else '—'} \n"
565
+ f"Popularity {int(a.get('popularity_score',0))}/10 • Comfort {int(a.get('comfort_score',0))}/10 • "
566
+ f"Reliability {int(a.get('reliability_score',0))}/100 • Safety {int(a.get('safety_rating',0))}★")
567
+
568
+ cards = []
569
+ for _, r in out.head(3).iterrows():
570
+ img = wiki_image(r.get("make",""), r.get("model",""), r.get("year",None))
571
+ title = f"{r.get('make','')} {r.get('model','')} {str(r.get('trim_display','')).strip()}"
572
+ cards.append((img, title))
573
+ return None, table, anchor_md, cards
574
+
575
+ # --------- Build UI ----------
576
+ def build_app():
577
+ df = unify_columns(load_df())
578
+ # repair acceleration if clearly broken
579
+ if suspicious_zero_to_100(df["zero_to_100_kmh_s"]):
580
+ df["zero_to_100_kmh_s"] = df.apply(estimate_0_100, axis=1)
581
+
582
+ Etext, Enum = ensure_embeddings(df)
583
+
584
+ learned = learned_trims_from_csv(df)
585
+ makes = sorted(df["make"].dropna().astype(str).unique().tolist())
586
+ body_choices = ["Any"] + sorted([b for b in df["body_type"].dropna().astype(str).unique().tolist() if b])
587
+ fuel_choices = ["Any"] + sorted([f for f in df["fuel"].dropna().astype(str).unique().tolist() if f])
588
+
589
+ y_lo, y_hi = int(df["year"].min(skipna=True)), int(df["year"].max(skipna=True))
590
+ p_lo, p_hi = int(df["price_usd"].min(skipna=True)), int(df["price_usd"].max(skipna=True))
591
+
592
+ def models_for(make):
593
+ if not make: return gr.update(choices=[], value=None)
594
+ opts = sorted(df.loc[df["make"].eq(make), "model"].dropna().astype(str).unique().tolist())
595
+ return gr.update(choices=opts, value=None)
596
+
597
+ def trims_years_for(make, model):
598
+ if not make or not model:
599
+ return gr.update(choices=[], value=None), gr.update(choices=[], value=None)
600
+ trims = REAL_WORLD_TRIMS.get((make, model)) or learned.get((make, model)) or ["Base"]
601
+ years = sorted(pd.to_numeric(df[(df["make"]==make)&(df["model"]==model)]["year"], errors="coerce").dropna().astype(int).unique().tolist())
602
+ return gr.update(choices=trims, value=None), gr.update(choices=[None]+years, value=None)
603
+
604
+ with gr.Blocks(theme=gr.themes.Soft(), title="RideSearch") as demo:
605
+ gr.Markdown("## RideSearch — cross‑brand recommendations with realistic trims & photos")
606
 
607
  with gr.Tab("Pick & Recommend"):
608
  with gr.Row():
609
+ mk = gr.Dropdown(makes, label="Make")
610
+ md = gr.Dropdown([], label="Model")
611
+ tr = gr.Dropdown([], label="Trim (optional)")
612
+ yr = gr.Dropdown([], label="Year (optional)")
 
 
 
 
 
 
613
  mk.change(models_for, mk, md)
614
+ md.change(lambda a,b: trims_years_for(a,b), [mk, md], [tr, yr])
615
 
616
  with gr.Row():
617
+ body = gr.Dropdown(body_choices, value="Any", label="Body")
618
+ fuel = gr.Dropdown(fuel_choices, value="Any", label="Fuel")
 
 
 
 
 
 
 
 
 
619
  with gr.Row():
620
+ y_min = gr.Slider(y_lo, y_hi, value=max(y_lo, y_lo), step=1, label="Year min")
621
+ y_max = gr.Slider(y_lo, y_hi, value=y_hi, step=1, label="Year max")
 
622
  with gr.Row():
623
+ p_min = gr.Slider(p_lo, p_hi, value=p_lo, step=500, label="Price min (USD)")
624
+ p_max = gr.Slider(p_lo, p_hi, value=min(p_hi, max(p_lo+15000, p_lo+5000)), step=500, label="Price max (USD)")
 
625
  with gr.Row():
626
+ safety = gr.Slider(3, 5, value=4, step=1, label="Min Safety ★")
627
+ reliab = gr.Slider(55, 99, value=70, step=1, label="Min Reliability")
 
628
  with gr.Row():
629
+ topk = gr.Slider(1, 10, value=5, step=1, label="Recommendations")
630
+ alpha = gr.Slider(0, 1, value=0.7, step=0.05, label="α — Text vs Numeric")
 
631
  with gr.Row():
632
+ cross = gr.Checkbox(True, label="Cross‑brand only")
633
+ xmodel = gr.Checkbox(True, label="Exclude same model family")
634
+ uniqb = gr.Checkbox(True, label="Unique brands (no repeats)")
635
+
636
+ run = gr.Button("Recommend", variant="primary")
637
+ err = gr.Markdown()
638
+ anchor_md = gr.Markdown()
639
+ table = gr.Dataframe(interactive=False, wrap=True, label="Recommendations")
640
+ gallery = gr.Gallery(label="Photos", height=220, columns=[3])
641
+
642
+ def on_click(a,b,c,d,e,f,g,h,i,j,k,l,m,n,o):
643
+ msg, tbl, atext, cards = recommend(
644
+ df, Etext, Enum, a,b,c,d,e,f,int(g),int(h),int(i),int(j),int(k),int(l),
645
+ cross_brand_only=m, exclude_same_model=n, unique_brand=o
646
+ )
647
+ if msg:
648
+ return gr.update(value=f"**{msg}**"), gr.update(value=""), pd.DataFrame(), []
649
+ return gr.update(value=""), gr.update(value=atext), tbl, cards
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
 
651
+ run.click(on_click,
652
+ [mk, md, tr, yr, topk, alpha, body, fuel, y_min, y_max, p_min, p_max, safety, reliab, cross, xmodel, uniqb],
653
+ [err, anchor_md, table, gallery])
654
 
655
+ gr.Markdown("Tip: First run may be slower (embeddings build). Next runs are fast.")
656
+
657
+ return demo
658
 
659
  if __name__ == "__main__":
660
+ demo = build_app()
661
  demo.queue().launch(server_name="0.0.0.0", server_port=7860)