Levimichael4 commited on
Commit
075b89f
Β·
verified Β·
1 Parent(s): a77b6fe

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +140 -67
app.py CHANGED
@@ -2,23 +2,27 @@ import os, glob, json, requests
2
  import numpy as np
3
  import pandas as pd
4
  import gradio as gr
5
- from sklearn.preprocessing import StandardScaler
6
 
 
7
  try:
8
  from sentence_transformers import SentenceTransformer
9
  except Exception:
10
  SentenceTransformer = None
 
 
 
 
11
  try:
12
  from sklearn.feature_extraction.text import HashingVectorizer
13
  except Exception:
14
  HashingVectorizer = None
15
 
16
- CSV_MAIN = "RideSearch_dataset.csv"
17
- CSV_PARTS_GLOB = "RideSearch_part*_small.csv"
18
- EMB_TEXT_NPY = "emb_text.npy"
19
- EMB_NUM_NPY = "emb_num.npy"
20
  TRIMS_OVERRIDES = "trims_overrides.json"
21
- EMBED_MODEL = os.environ.get("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
22
 
23
  NUM_COLS_CANON = [
24
  "horsepower","zero_to_100_kmh_s","seats","cargo_liters","price_usd",
@@ -308,6 +312,7 @@ REAL_TRIMS = {
308
  def _norm(x): return str(x or "").strip().lower()
309
  REAL_TRIMS_N = {(_norm(mk), _norm(md)): trims for (mk, md), trims in REAL_TRIMS.items()}
310
 
 
311
  def load_df():
312
  if os.path.exists(CSV_MAIN):
313
  return pd.read_csv(CSV_MAIN)
@@ -316,26 +321,33 @@ def load_df():
316
  df = pd.concat([pd.read_csv(p) for p in parts], ignore_index=True)
317
  df.to_csv(CSV_MAIN, index=False)
318
  return df
319
- raise FileNotFoundError("Upload RideSearch_dataset.csv or the parts RideSearch_part*_small.csv.")
320
 
321
  def unify_columns(df: pd.DataFrame) -> pd.DataFrame:
322
  df = df.copy()
 
323
  if "popularity" in df.columns and "popularity_score" not in df.columns: df["popularity_score"] = df["popularity"]
324
- if "comfort" in df.columns and "comfort_score" not in df.columns: df["comfort_score"] = df["comfort"]
325
  if "reliability" in df.columns and "reliability_score" not in df.columns: df["reliability_score"] = df["reliability"]
326
- if "safety" in df.columns and "safety_rating" not in df.columns: df["safety_rating"] = df["safety"]
327
- if "trim_display" not in df.columns: df["trim_display"] = df["trim"] if "trim" in df.columns else ""
 
 
 
328
  for c in NUM_COLS_CANON:
329
  if c not in df.columns: df[c] = np.nan
330
- for c in NUM_COLS_CANON: df[c] = pd.to_numeric(df[c], errors="coerce")
 
331
  if "text_record" not in df.columns:
332
- cols = ["make","model","trim_display","body_type","fuel","engine_type"]
333
- cols = [c for c in cols if c in df.columns]
334
  df["text_record"] = df[cols].fillna("").astype(str).agg(" ".join, axis=1)
335
- for col in ["make","model","body_type","fuel","engine_type","name"]:
336
- if col not in df.columns: df[col] = ""
 
 
337
  return df
338
 
 
339
  def suspicious_zero_to_100(series: pd.Series) -> bool:
340
  s = series.dropna()
341
  if len(s) < 20: return False
@@ -347,136 +359,185 @@ def estimate_0_100(row):
347
  body = str(row.get("body_type","")).lower()
348
  fuel = str(row.get("fuel","")).lower()
349
  trim = f"{row.get('model','')} {row.get('trim_display','')}".lower()
 
350
  base = 26.0 - 3.2*np.log(max(hp, 60.0))
351
  if any(k in body for k in ["suv","crossover","pickup","truck","van"]): base += 0.7
352
  if any(k in body for k in ["coupe","roadster"]): base -= 0.4
353
  if "electric" in fuel or "ev" in fuel: base -= 0.8
354
  if "hybrid" in fuel: base -= 0.3
355
  if "diesel" in fuel: base += 0.2
356
- if any(x in trim for x in [" m "," amg","rs","type r","sti","gts","gt3","hellcat","svr","cupra","john cooper works"]): base -= 0.6
 
357
  return float(np.clip(round(base,2), 2.8, 14.5))
358
 
 
359
  def ensure_embeddings(df):
360
- txt_ok = os.path.exists(EMB_TEXT_NPY)
361
- num_ok = os.path.exists(EMB_NUM_NPY)
362
- if txt_ok and num_ok:
363
  return np.load(EMB_TEXT_NPY), np.load(EMB_NUM_NPY)
 
 
364
  if SentenceTransformer is not None:
365
  model = SentenceTransformer(EMBED_MODEL)
366
- Etext = model.encode(df["text_record"].astype(str).tolist(), batch_size=256, show_progress_bar=False, normalize_embeddings=True).astype("float32")
 
 
367
  else:
368
- if HashingVectorizer is None: raise RuntimeError("Install sentence-transformers or scikit-learn for text embeddings.")
 
369
  hv = HashingVectorizer(n_features=512, alternate_sign=False, norm="l2")
370
  Etext = hv.transform(df["text_record"].fillna("").tolist()).toarray().astype("float32")
371
  np.save(EMB_TEXT_NPY, Etext)
372
- X = df[["horsepower","zero_to_100_kmh_s","seats","cargo_liters","price_usd","popularity_score","comfort_score","reliability_score","tech_score","ownership_cost_score","safety_rating"]].copy()
 
 
 
 
373
  X = X.fillna(X.mean(numeric_only=True))
 
 
374
  scaler = StandardScaler()
375
  Enum = scaler.fit_transform(X.values.astype("float32")).astype("float32")
376
  np.save(EMB_NUM_NPY, Enum)
377
  return Etext, Enum
378
 
 
379
  def load_overrides():
380
  if os.path.exists(TRIMS_OVERRIDES):
381
  try:
382
- with open(TRIMS_OVERRIDES, "r", encoding="utf-8") as f: return json.load(f)
383
- except Exception: return {}
 
 
384
  return {}
385
 
386
  def save_overrides(data):
387
- with open(TRIMS_OVERRIDES, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False)
 
388
 
389
  def learned_trims_from_csv(df):
390
  out = {}
391
- for (mk, md), sub in df.groupby(["make", "model"]):
392
- vals = (sub["trim_display"].dropna().astype(str).map(str.strip).replace({"": np.nan}).dropna().value_counts().index.tolist())
393
- if vals: out[(_norm(mk), _norm(md))] = vals[:20]
 
 
 
394
  return out
395
 
396
  def get_trims_for(make, model, learned, overrides):
397
- if overrides.get(make, {}).get(model): return overrides[make][model]
398
- mk, md = _norm(make), _norm(model)
399
- if (mk, md) in REAL_TRIMS_N: return REAL_TRIMS_N[(mk, md)]
400
- if (mk, md) in learned: return learned[(mk, md)]
 
401
  return ["Base"]
402
 
 
403
  def wiki_image(make, model, year=None):
404
  q = f"{year} {make} {model}" if year else f"{make} {model}"
405
  try:
406
- r = requests.get("https://en.wikipedia.org/w/api.php", params={"action":"query","format":"json","prop":"pageimages","piprop":"thumbnail","pithumbsize":600,"generator":"search","gsrsearch":q,"gsrlimit":1,"gsrnamespace":0}, timeout=6).json()
 
 
 
 
 
 
407
  pages = r.get("query",{}).get("pages",{})
408
  if pages:
409
- page = next(iter(pages.values()))
410
- return page.get("thumbnail",{}).get("source")
411
  except Exception:
412
  pass
413
  return None
414
 
415
- def cosine_sim_row_vs_mat(mat, vec):
 
416
  mat = mat / (np.linalg.norm(mat, axis=1, keepdims=True)+1e-9)
417
  v = vec / (np.linalg.norm(vec)+1e-9)
418
  return mat @ v
419
 
420
- def recommend(df, Etext, Enum, make, model, trim, year, topk, alpha, body, fuel, y_min, y_max, p_min, p_max, safety, reliab, cross_brand_only=True, exclude_same_model=True, unique_brand=True):
 
 
 
421
  sub = df[(df["make"]==make) & (df["model"]==model)].copy()
422
  if trim: sub = sub[sub["trim_display"].astype(str).str.lower()==str(trim).lower()]
423
  if year: sub = sub[pd.to_numeric(sub["year"], errors="coerce")==int(year)]
424
  if sub.empty:
425
  sub = df[(df["make"]==make) & (df["model"]==model)].copy()
426
  if sub.empty: return "No such make/model in dataset.", None, "", []
 
427
  a = sub.sort_values("popularity_score", ascending=False).iloc[0]
428
  a_idx = int(a.name)
 
429
  pool = df.copy()
430
- if cross_brand_only: pool = pool[pool["make"] != a["make"]]
431
  if exclude_same_model: pool = pool[~((pool["make"]==a["make"]) & (pool["model"]==a["model"]))]
 
432
  if body and body!="Any": pool = pool[pool["body_type"]==body]
433
  if fuel and fuel!="Any": pool = pool[pool["fuel"]==fuel]
434
  pool = pool[(pool["year"]>=y_min) & (pool["year"]<=y_max)]
435
  pool = pool[(pool["price_usd"]>=p_min) & (pool["price_usd"]<=p_max)]
436
  pool = pool[(pool["safety_rating"]>=safety) & (pool["reliability_score"]>=reliab)]
437
  if pool.empty: return "No cars after your filters. Loosen price/year/safety.", None, "", []
 
438
  cand_idx = pool.index.values
439
- st = cosine_sim_row_vs_mat(Etext[cand_idx], Etext[a_idx])
440
- sn = cosine_sim_row_vs_mat(Enum[cand_idx], Enum[a_idx])
441
- s = float(alpha)*st + (1-float(alpha))*sn
442
  order = np.argsort(-s)
 
443
  chosen, seen = [], set()
444
  for j in order:
445
  k = cand_idx[j]
446
  brand = str(df.loc[k,"make"]).lower()
447
  if unique_brand and brand in seen: continue
448
- seen.add(brand)
449
- chosen.append(k)
450
  if len(chosen) >= int(topk): break
451
  if not chosen: return "No recommendations after constraints.", None, "", []
 
452
  out = df.loc[chosen].copy()
453
  sim_lookup = {cand_idx[i]: float(s[i]) for i in range(len(cand_idx))}
454
  out["similarity_%"] = [round(sim_lookup[k]*100,1) for k in chosen]
455
- cols = ["name","make","model","trim_display","year","body_type","fuel","engine_type","price_usd","horsepower","zero_to_100_kmh_s","popularity_score","comfort_score","reliability_score","tech_score","ownership_cost_score","safety_rating","similarity_%"]
 
 
 
456
  table = out[[c for c in cols if c in out.columns]].reset_index(drop=True)
 
457
  disp_trim = str(a.get("trim_display","")).strip()
458
- anchor_md = (f"**{a['make']} {a['model']} {disp_trim} {int(a['year']) if pd.notna(a['year']) else ''}** \n"
459
- f"Body: {a.get('body_type','')} β€’ Fuel: {a.get('fuel','')} β€’ Engine: {a.get('engine_type','')} \n"
460
- f"HP: {int(a['horsepower']) if pd.notna(a['horsepower']) else 'β€”'} β€’ 0–100: {a.get('zero_to_100_kmh_s','β€”')}s "
461
- f"β€’ Price: ${int(a['price_usd']) if pd.notna(a['price_usd']) else 'β€”'} \n"
462
- f"Popularity {int(a.get('popularity_score',0))}/10 β€’ Comfort {int(a.get('comfort_score',0))}/10 β€’ "
463
- f"Reliability {int(a.get('reliability_score',0))}/100 β€’ Safety {int(a.get('safety_rating',0))}β˜…")
 
 
 
464
  cards = []
465
  for _, r in out.head(3).iterrows():
466
  img = wiki_image(r.get("make",""), r.get("model",""), r.get("year",None))
467
  title = f"{r.get('make','')} {r.get('model','')} {str(r.get('trim_display','')).strip()}"
468
  cards.append((img, title))
 
469
  return None, table, anchor_md, cards
470
 
 
471
  def build_app():
472
  df = unify_columns(load_df())
473
- if suspicious_zero_to_100(df["zero_to_100_kmh_s"]): df["zero_to_100_kmh_s"] = df.apply(estimate_0_100, axis=1)
 
 
474
  Etext, Enum = ensure_embeddings(df)
475
- learned = learned_trims_from_csv(df)
 
476
  overrides = load_overrides()
 
477
  makes = sorted(df["make"].dropna().astype(str).unique().tolist())
478
  body_choices = ["Any"] + sorted([b for b in df["body_type"].dropna().astype(str).unique().tolist() if b])
479
  fuel_choices = ["Any"] + sorted([f for f in df["fuel"].dropna().astype(str).unique().tolist() if f])
 
480
  y_lo, y_hi = int(df["year"].min(skipna=True)), int(df["year"].max(skipna=True))
481
  p_lo, p_hi = int(df["price_usd"].min(skipna=True)), int(df["price_usd"].max(skipna=True))
482
 
@@ -486,13 +547,16 @@ def build_app():
486
  return gr.update(choices=opts, value=None)
487
 
488
  def trims_years_for(make, model):
489
- if not make or not model: return gr.update(choices=[], value=None), gr.update(choices=[], value=None)
 
490
  trims = get_trims_for(make, model, learned, overrides)
491
- years = sorted(pd.to_numeric(df[(df["make"]==make)&(df["model"]==model)]["year"], errors="coerce").dropna().astype(int).unique().tolist())
 
492
  return gr.update(choices=trims, value=None), gr.update(choices=[None]+years, value=None)
493
 
494
  with gr.Blocks(theme=gr.themes.Soft(), title="RideSearch") as demo:
495
  gr.Markdown("## RideSearch β€” cross-brand recommendations with realistic trims & photos")
 
496
  with gr.Tab("Pick & Recommend"):
497
  with gr.Row():
498
  mk = gr.Dropdown(makes, label="Make")
@@ -501,6 +565,7 @@ def build_app():
501
  yr = gr.Dropdown([], label="Year (optional)")
502
  mk.change(models_for, mk, md)
503
  md.change(lambda a,b: trims_years_for(a,b), [mk, md], [tr, yr])
 
504
  with gr.Row():
505
  body = gr.Dropdown(body_choices, value="Any", label="Body")
506
  fuel = gr.Dropdown(fuel_choices, value="Any", label="Fuel")
@@ -517,30 +582,38 @@ def build_app():
517
  topk = gr.Slider(1, 10, value=5, step=1, label="Recommendations")
518
  alpha = gr.Slider(0, 1, value=0.7, step=0.05, label="Ξ± β€” Text vs Numeric")
519
  with gr.Row():
520
- cross = gr.Checkbox(True, label="Cross-brand only")
521
  xmodel = gr.Checkbox(True, label="Exclude same model family")
522
- uniqb = gr.Checkbox(True, label="Unique brands (no repeats)")
 
523
  run = gr.Button("Recommend", variant="primary")
524
  err = gr.Markdown()
525
  anchor_md = gr.Markdown()
526
  table = gr.Dataframe(interactive=False, wrap=True, label="Recommendations")
527
  gallery = gr.Gallery(label="Photos", height=220, columns=[3])
528
 
529
- def on_click(mk_, md_, tr_, yr_, topk_, alpha_, body_, fuel_, y_min_, y_max_, p_min_, p_max_, safety_, reliab_, cross_, xmodel_, uniqb_):
 
 
530
  msg, tbl, atext, cards = recommend(
531
- df, Etext, Enum, mk_, md_, tr_, yr_, int(topk_), float(alpha_),
532
- body_, fuel_, int(y_min_), int(y_max_), int(p_min_), int(p_max_), int(safety_), int(reliab_),
 
 
533
  cross_brand_only=bool(cross_), exclude_same_model=bool(xmodel_), unique_brand=bool(uniqb_)
534
  )
535
- if msg: return gr.update(value=f"**{msg}**"), gr.update(value=""), pd.DataFrame(), []
 
536
  return gr.update(value=""), gr.update(value=atext), tbl, cards
537
 
538
- run.click(on_click,
539
- [mk, md, tr, yr, topk, alpha, body, fuel, y_min, y_max, p_min, p_max, safety, reliab, cross, xmodel, uniqb],
540
- [err, anchor_md, table, gallery])
 
 
541
 
542
  with gr.Tab("Admin β€’ Trim Overrides"):
543
- gr.Markdown("Paste correct trims (one per line) for a model. Saved to trims_overrides.json.")
544
  a_mk = gr.Dropdown(makes, label="Make")
545
  a_md = gr.Dropdown([], label="Model")
546
  a_txt = gr.Textbox(lines=8, label="Display trims (one per line)")
@@ -551,9 +624,9 @@ def build_app():
551
  def do_save(make, model, txt):
552
  nonlocal overrides
553
  trims = [t.strip() for t in str(txt).splitlines() if t.strip()]
554
- if not make or not model or not trims: return "⚠️ Provide make, model, and at least one trim."
555
- if make not in overrides: overrides[make] = {}
556
- overrides[make][model] = trims
557
  save_overrides(overrides)
558
  return f"βœ… Saved {len(trims)} trims for {make} {model}. Refresh the Pick tab."
559
  save_btn.click(do_save, [a_mk, a_md, a_txt], save_msg)
 
2
  import numpy as np
3
  import pandas as pd
4
  import gradio as gr
 
5
 
6
+ # ---- optional deps with safe fallbacks ----
7
  try:
8
  from sentence_transformers import SentenceTransformer
9
  except Exception:
10
  SentenceTransformer = None
11
+ try:
12
+ from sklearn.preprocessing import StandardScaler
13
+ except Exception:
14
+ StandardScaler = None
15
  try:
16
  from sklearn.feature_extraction.text import HashingVectorizer
17
  except Exception:
18
  HashingVectorizer = None
19
 
20
+ CSV_MAIN = "RideSearch_dataset.csv"
21
+ CSV_PARTS_GLOB = "RideSearch_part*_small.csv"
22
+ EMB_TEXT_NPY = "emb_text.npy"
23
+ EMB_NUM_NPY = "emb_num.npy"
24
  TRIMS_OVERRIDES = "trims_overrides.json"
25
+ EMBED_MODEL = os.environ.get("EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
26
 
27
  NUM_COLS_CANON = [
28
  "horsepower","zero_to_100_kmh_s","seats","cargo_liters","price_usd",
 
312
  def _norm(x): return str(x or "").strip().lower()
313
  REAL_TRIMS_N = {(_norm(mk), _norm(md)): trims for (mk, md), trims in REAL_TRIMS.items()}
314
 
315
+ # --------- data loading / unification ----------
316
  def load_df():
317
  if os.path.exists(CSV_MAIN):
318
  return pd.read_csv(CSV_MAIN)
 
321
  df = pd.concat([pd.read_csv(p) for p in parts], ignore_index=True)
322
  df.to_csv(CSV_MAIN, index=False)
323
  return df
324
+ raise FileNotFoundError("Upload RideSearch_dataset.csv or RideSearch_part*_small.csv files.")
325
 
326
  def unify_columns(df: pd.DataFrame) -> pd.DataFrame:
327
  df = df.copy()
328
+
329
  if "popularity" in df.columns and "popularity_score" not in df.columns: df["popularity_score"] = df["popularity"]
330
+ if "comfort" in df.columns and "comfort_score" not in df.columns: df["comfort_score"] = df["comfort"]
331
  if "reliability" in df.columns and "reliability_score" not in df.columns: df["reliability_score"] = df["reliability"]
332
+ if "safety" in df.columns and "safety_rating" not in df.columns: df["safety_rating"] = df["safety"]
333
+
334
+ if "trim_display" not in df.columns:
335
+ df["trim_display"] = df["trim"] if "trim" in df.columns else ""
336
+
337
  for c in NUM_COLS_CANON:
338
  if c not in df.columns: df[c] = np.nan
339
+ df[c] = pd.to_numeric(df[c], errors="coerce")
340
+
341
  if "text_record" not in df.columns:
342
+ cols = [c for c in ["make","model","trim_display","body_type","fuel","engine_type"] if c in df.columns]
 
343
  df["text_record"] = df[cols].fillna("").astype(str).agg(" ".join, axis=1)
344
+
345
+ for c in ["make","model","body_type","fuel","engine_type","name"]:
346
+ if c not in df.columns: df[c] = ""
347
+
348
  return df
349
 
350
+ # --------- quality fix for 0–100 ----------
351
  def suspicious_zero_to_100(series: pd.Series) -> bool:
352
  s = series.dropna()
353
  if len(s) < 20: return False
 
359
  body = str(row.get("body_type","")).lower()
360
  fuel = str(row.get("fuel","")).lower()
361
  trim = f"{row.get('model','')} {row.get('trim_display','')}".lower()
362
+
363
  base = 26.0 - 3.2*np.log(max(hp, 60.0))
364
  if any(k in body for k in ["suv","crossover","pickup","truck","van"]): base += 0.7
365
  if any(k in body for k in ["coupe","roadster"]): base -= 0.4
366
  if "electric" in fuel or "ev" in fuel: base -= 0.8
367
  if "hybrid" in fuel: base -= 0.3
368
  if "diesel" in fuel: base += 0.2
369
+ if any(x in trim for x in [" m "," amg","rs","type r","sti","gts","gt3","hellcat","svr","cupra","john cooper works"]):
370
+ base -= 0.6
371
  return float(np.clip(round(base,2), 2.8, 14.5))
372
 
373
+ # --------- embeddings ----------
374
  def ensure_embeddings(df):
375
+ if os.path.exists(EMB_TEXT_NPY) and os.path.exists(EMB_NUM_NPY):
 
 
376
  return np.load(EMB_TEXT_NPY), np.load(EMB_NUM_NPY)
377
+
378
+ # text
379
  if SentenceTransformer is not None:
380
  model = SentenceTransformer(EMBED_MODEL)
381
+ Etext = model.encode(df["text_record"].astype(str).tolist(),
382
+ batch_size=256, show_progress_bar=False,
383
+ normalize_embeddings=True).astype("float32")
384
  else:
385
+ if HashingVectorizer is None:
386
+ raise RuntimeError("Need sentence-transformers or scikit-learn for text embeddings.")
387
  hv = HashingVectorizer(n_features=512, alternate_sign=False, norm="l2")
388
  Etext = hv.transform(df["text_record"].fillna("").tolist()).toarray().astype("float32")
389
  np.save(EMB_TEXT_NPY, Etext)
390
+
391
+ # numeric
392
+ X = df[["horsepower","zero_to_100_kmh_s","seats","cargo_liters","price_usd",
393
+ "popularity_score","comfort_score","reliability_score","tech_score",
394
+ "ownership_cost_score","safety_rating"]].copy()
395
  X = X.fillna(X.mean(numeric_only=True))
396
+ if StandardScaler is None:
397
+ raise RuntimeError("scikit-learn missing: add it to requirements.txt")
398
  scaler = StandardScaler()
399
  Enum = scaler.fit_transform(X.values.astype("float32")).astype("float32")
400
  np.save(EMB_NUM_NPY, Enum)
401
  return Etext, Enum
402
 
403
+ # --------- trims (learned + overrides + curated) ----------
404
  def load_overrides():
405
  if os.path.exists(TRIMS_OVERRIDES):
406
  try:
407
+ with open(TRIMS_OVERRIDES, "r", encoding="utf-8") as f:
408
+ return json.load(f)
409
+ except Exception:
410
+ return {}
411
  return {}
412
 
413
  def save_overrides(data):
414
+ with open(TRIMS_OVERRIDES, "w", encoding="utf-8") as f:
415
+ json.dump(data, f, indent=2, ensure_ascii=False)
416
 
417
  def learned_trims_from_csv(df):
418
  out = {}
419
+ for (mk, md), sub in df.groupby(["make","model"]):
420
+ vals = (sub["trim_display"].dropna().astype(str).map(str.strip))
421
+ vals = [v for v in vals if v and v.lower() not in ("nan","none")]
422
+ if vals:
423
+ top = pd.Series(vals).value_counts().index.tolist()[:20]
424
+ out[(_norm(mk), _norm(md))] = top
425
  return out
426
 
427
  def get_trims_for(make, model, learned, overrides):
428
+ if overrides.get(make, {}).get(model):
429
+ return overrides[make][model]
430
+ key = (_norm(make), _norm(model))
431
+ if key in learned: return learned[key]
432
+ if key in REAL_TRIMS_N: return REAL_TRIMS_N[key]
433
  return ["Base"]
434
 
435
+ # --------- images ----------
436
  def wiki_image(make, model, year=None):
437
  q = f"{year} {make} {model}" if year else f"{make} {model}"
438
  try:
439
+ r = requests.get(
440
+ "https://en.wikipedia.org/w/api.php",
441
+ params={"action":"query","format":"json","prop":"pageimages",
442
+ "piprop":"thumbnail","pithumbsize":600,
443
+ "generator":"search","gsrsearch":q,"gsrlimit":1,"gsrnamespace":0},
444
+ timeout=6
445
+ ).json()
446
  pages = r.get("query",{}).get("pages",{})
447
  if pages:
448
+ return next(iter(pages.values())).get("thumbnail",{}).get("source")
 
449
  except Exception:
450
  pass
451
  return None
452
 
453
+ # --------- recommend ----------
454
+ def _cosine(mat, vec):
455
  mat = mat / (np.linalg.norm(mat, axis=1, keepdims=True)+1e-9)
456
  v = vec / (np.linalg.norm(vec)+1e-9)
457
  return mat @ v
458
 
459
+ def recommend(df, Etext, Enum, make, model, trim, year, topk, alpha,
460
+ body, fuel, y_min, y_max, p_min, p_max, safety, reliab,
461
+ cross_brand_only=True, exclude_same_model=True, unique_brand=True):
462
+
463
  sub = df[(df["make"]==make) & (df["model"]==model)].copy()
464
  if trim: sub = sub[sub["trim_display"].astype(str).str.lower()==str(trim).lower()]
465
  if year: sub = sub[pd.to_numeric(sub["year"], errors="coerce")==int(year)]
466
  if sub.empty:
467
  sub = df[(df["make"]==make) & (df["model"]==model)].copy()
468
  if sub.empty: return "No such make/model in dataset.", None, "", []
469
+
470
  a = sub.sort_values("popularity_score", ascending=False).iloc[0]
471
  a_idx = int(a.name)
472
+
473
  pool = df.copy()
474
+ if cross_brand_only: pool = pool[pool["make"] != a["make"]]
475
  if exclude_same_model: pool = pool[~((pool["make"]==a["make"]) & (pool["model"]==a["model"]))]
476
+
477
  if body and body!="Any": pool = pool[pool["body_type"]==body]
478
  if fuel and fuel!="Any": pool = pool[pool["fuel"]==fuel]
479
  pool = pool[(pool["year"]>=y_min) & (pool["year"]<=y_max)]
480
  pool = pool[(pool["price_usd"]>=p_min) & (pool["price_usd"]<=p_max)]
481
  pool = pool[(pool["safety_rating"]>=safety) & (pool["reliability_score"]>=reliab)]
482
  if pool.empty: return "No cars after your filters. Loosen price/year/safety.", None, "", []
483
+
484
  cand_idx = pool.index.values
485
+ st = _cosine(Etext[cand_idx], Etext[a_idx])
486
+ sn = _cosine(Enum[cand_idx], Enum[a_idx])
487
+ s = float(alpha)*st + (1-float(alpha))*sn
488
  order = np.argsort(-s)
489
+
490
  chosen, seen = [], set()
491
  for j in order:
492
  k = cand_idx[j]
493
  brand = str(df.loc[k,"make"]).lower()
494
  if unique_brand and brand in seen: continue
495
+ seen.add(brand); chosen.append(k)
 
496
  if len(chosen) >= int(topk): break
497
  if not chosen: return "No recommendations after constraints.", None, "", []
498
+
499
  out = df.loc[chosen].copy()
500
  sim_lookup = {cand_idx[i]: float(s[i]) for i in range(len(cand_idx))}
501
  out["similarity_%"] = [round(sim_lookup[k]*100,1) for k in chosen]
502
+
503
+ cols = ["name","make","model","trim_display","year","body_type","fuel","engine_type",
504
+ "price_usd","horsepower","zero_to_100_kmh_s","popularity_score","comfort_score",
505
+ "reliability_score","tech_score","ownership_cost_score","safety_rating","similarity_%"]
506
  table = out[[c for c in cols if c in out.columns]].reset_index(drop=True)
507
+
508
  disp_trim = str(a.get("trim_display","")).strip()
509
+ anchor_md = (
510
+ f"**{a['make']} {a['model']} {disp_trim} {int(a['year']) if pd.notna(a['year']) else ''}** \n"
511
+ f"Body: {a.get('body_type','')} β€’ Fuel: {a.get('fuel','')} β€’ Engine: {a.get('engine_type','')} \n"
512
+ f"HP: {int(a['horsepower']) if pd.notna(a['horsepower']) else 'β€”'} β€’ 0–100: {a.get('zero_to_100_kmh_s','β€”')}s "
513
+ f"β€’ Price: ${int(a['price_usd']) if pd.notna(a['price_usd']) else 'β€”'} \n"
514
+ f"Popularity {int(a.get('popularity_score',0))}/10 β€’ Comfort {int(a.get('comfort_score',0))}/10 β€’ "
515
+ f"Reliability {int(a.get('reliability_score',0))}/100 β€’ Safety {int(a.get('safety_rating',0))}β˜…"
516
+ )
517
+
518
  cards = []
519
  for _, r in out.head(3).iterrows():
520
  img = wiki_image(r.get("make",""), r.get("model",""), r.get("year",None))
521
  title = f"{r.get('make','')} {r.get('model','')} {str(r.get('trim_display','')).strip()}"
522
  cards.append((img, title))
523
+
524
  return None, table, anchor_md, cards
525
 
526
+ # --------- UI ----------
527
  def build_app():
528
  df = unify_columns(load_df())
529
+ if suspicious_zero_to_100(df["zero_to_100_kmh_s"]):
530
+ df["zero_to_100_kmh_s"] = df.apply(estimate_0_100, axis=1)
531
+
532
  Etext, Enum = ensure_embeddings(df)
533
+
534
+ learned = learned_trims_from_csv(df)
535
  overrides = load_overrides()
536
+
537
  makes = sorted(df["make"].dropna().astype(str).unique().tolist())
538
  body_choices = ["Any"] + sorted([b for b in df["body_type"].dropna().astype(str).unique().tolist() if b])
539
  fuel_choices = ["Any"] + sorted([f for f in df["fuel"].dropna().astype(str).unique().tolist() if f])
540
+
541
  y_lo, y_hi = int(df["year"].min(skipna=True)), int(df["year"].max(skipna=True))
542
  p_lo, p_hi = int(df["price_usd"].min(skipna=True)), int(df["price_usd"].max(skipna=True))
543
 
 
547
  return gr.update(choices=opts, value=None)
548
 
549
  def trims_years_for(make, model):
550
+ if not make or not model:
551
+ return gr.update(choices=[], value=None), gr.update(choices=[], value=None)
552
  trims = get_trims_for(make, model, learned, overrides)
553
+ years = sorted(pd.to_numeric(df[(df["make"]==make)&(df["model"]==model)]["year"],
554
+ errors="coerce").dropna().astype(int).unique().tolist())
555
  return gr.update(choices=trims, value=None), gr.update(choices=[None]+years, value=None)
556
 
557
  with gr.Blocks(theme=gr.themes.Soft(), title="RideSearch") as demo:
558
  gr.Markdown("## RideSearch β€” cross-brand recommendations with realistic trims & photos")
559
+
560
  with gr.Tab("Pick & Recommend"):
561
  with gr.Row():
562
  mk = gr.Dropdown(makes, label="Make")
 
565
  yr = gr.Dropdown([], label="Year (optional)")
566
  mk.change(models_for, mk, md)
567
  md.change(lambda a,b: trims_years_for(a,b), [mk, md], [tr, yr])
568
+
569
  with gr.Row():
570
  body = gr.Dropdown(body_choices, value="Any", label="Body")
571
  fuel = gr.Dropdown(fuel_choices, value="Any", label="Fuel")
 
582
  topk = gr.Slider(1, 10, value=5, step=1, label="Recommendations")
583
  alpha = gr.Slider(0, 1, value=0.7, step=0.05, label="Ξ± β€” Text vs Numeric")
584
  with gr.Row():
585
+ cross = gr.Checkbox(True, label="Cross-brand only")
586
  xmodel = gr.Checkbox(True, label="Exclude same model family")
587
+ uniqb = gr.Checkbox(True, label="Unique brands (no repeats)")
588
+
589
  run = gr.Button("Recommend", variant="primary")
590
  err = gr.Markdown()
591
  anchor_md = gr.Markdown()
592
  table = gr.Dataframe(interactive=False, wrap=True, label="Recommendations")
593
  gallery = gr.Gallery(label="Photos", height=220, columns=[3])
594
 
595
+ def on_click(mk_, md_, tr_, yr_, topk_, alpha_,
596
+ body_, fuel_, y_min_, y_max_, p_min_, p_max_, safety_, reliab_,
597
+ cross_, xmodel_, uniqb_):
598
  msg, tbl, atext, cards = recommend(
599
+ df, Etext, Enum, mk_, md_, tr_, yr_,
600
+ int(topk_), float(alpha_),
601
+ body_, fuel_, int(y_min_), int(y_max_),
602
+ int(p_min_), int(p_max_), int(safety_), int(reliab_),
603
  cross_brand_only=bool(cross_), exclude_same_model=bool(xmodel_), unique_brand=bool(uniqb_)
604
  )
605
+ if msg:
606
+ return gr.update(value=f"**{msg}**"), gr.update(value=""), pd.DataFrame(), []
607
  return gr.update(value=""), gr.update(value=atext), tbl, cards
608
 
609
+ run.click(
610
+ on_click,
611
+ [mk, md, tr, yr, topk, alpha, body, fuel, y_min, y_max, p_min, p_max, safety, reliab, cross, xmodel, uniqb],
612
+ [err, anchor_md, table, gallery]
613
+ )
614
 
615
  with gr.Tab("Admin β€’ Trim Overrides"):
616
+ gr.Markdown("Paste correct trims per line for a model. Saved to trims_overrides.json.")
617
  a_mk = gr.Dropdown(makes, label="Make")
618
  a_md = gr.Dropdown([], label="Model")
619
  a_txt = gr.Textbox(lines=8, label="Display trims (one per line)")
 
624
  def do_save(make, model, txt):
625
  nonlocal overrides
626
  trims = [t.strip() for t in str(txt).splitlines() if t.strip()]
627
+ if not make or not model or not trims:
628
+ return "⚠️ Provide make, model, and at least one trim."
629
+ overrides.setdefault(make, {})[model] = trims
630
  save_overrides(overrides)
631
  return f"βœ… Saved {len(trims)} trims for {make} {model}. Refresh the Pick tab."
632
  save_btn.click(do_save, [a_mk, a_md, a_txt], save_msg)