Spaces:
Sleeping
Sleeping
| """ | |
| HomeMatch AI — Hugging Face Space (Gradio) | |
| ========================================== | |
| AI-powered real-estate recommendations with embeddings + grounded generation, | |
| in a polished real-estate-style UI with illustrative property imagery. | |
| - Artifacts read from HF Dataset repo: BarWachsman7/HomeMatch-AI-Dataset | |
| (local file fallback supported). Files/models cached so they load once. | |
| - Embedding model name read from homematch_embedding_model_info.json and loaded | |
| with SentenceTransformer (HF model hub). Filters applied BEFORE cosine ranking. | |
| - Generation: google/flan-t5-base (fallback google/flan-t5-small), deterministic, | |
| grounded only in selected listing facts. Banned broker/agent/agency/realtor | |
| wording is detected & cleaned. | |
| - Images live in ./assets and are assigned by property type (illustrative only). | |
| - top_k is capped at 4 in the UI to match 4 image sets per property type and | |
| avoid repeated image sets in the same search. | |
| No OpenAI, no paid APIs, no secret tokens. | |
| """ | |
| import os | |
| import re | |
| import io | |
| import json | |
| import base64 | |
| import functools | |
| import numpy as np | |
| import pandas as pd | |
| import gradio as gr | |
| # --------------------------------------------------------------------------- # | |
| # Configuration | |
| # --------------------------------------------------------------------------- # | |
| DATASET_REPO = "BarWachsman7/HomeMatch-AI-Dataset" | |
| F_METADATA = "homematch_embedding_metadata.csv" | |
| F_EMB = "homematch_embeddings.npy" | |
| F_MODEL = "homematch_embedding_model_info.json" | |
| PRIMARY_GEN_MODEL = "google/flan-t5-base" | |
| FALLBACK_GEN_MODEL = "google/flan-t5-small" | |
| DEFAULT_EMBED_MODEL = "sentence-transformers/multi-qa-MiniLM-L6-cos-v1" | |
| BANNED_TERMS = [ | |
| "broker", "brokers", "agent", "agents", | |
| "agency", "agencies", "realtor", "realtors" | |
| ] | |
| _BANNED_RE = re.compile( | |
| r"\b(brokers?|agents?|agenc(?:y|ies)|realtors?)\b", | |
| re.IGNORECASE | |
| ) | |
| REC_COLUMNS = [ | |
| "rank", "similarity_score", "listing_id", "city", "neighborhood", | |
| "property_type", "rooms", "size_sqm", "price_million_nis", | |
| "target_audience", "has_elevator", "has_parking", "has_balcony", | |
| "renovated", "near_schools", "distance_to_transport", "owner_name" | |
| ] | |
| ASSET_ROOT = "assets" | |
| PTYPE_TO_FOLDER = { | |
| "Apartment": "apartment", | |
| "Studio": "studio", | |
| "Private House": "private_house", | |
| "Penthouse": "penthouse", | |
| "Duplex": "duplex", | |
| "Garden Apartment": "garden_apartment", | |
| } | |
| IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".jfif"} | |
| # --------------------------------------------------------------------------- # | |
| # Small utilities | |
| # --------------------------------------------------------------------------- # | |
| def to_bool(value): | |
| if isinstance(value, bool): | |
| return value | |
| if isinstance(value, (int, float)): | |
| return value != 0 | |
| return str(value).strip().lower() in {"true", "1", "yes", "y", "t"} | |
| def _as_list(v): | |
| return list(v) if isinstance(v, (list, tuple, set)) else [v] | |
| def contains_banned_terms(text): | |
| return bool(_BANNED_RE.search(str(text))) | |
| def clean_banned_terms(text): | |
| return re.sub(r"\s{2,}", " ", _BANNED_RE.sub("owner", str(text))).strip() | |
| def generation_quality_check(text, context_terms=None): | |
| text = str(text) | |
| if context_terms is None: | |
| grounded = True | |
| else: | |
| terms = [ | |
| str(t).lower() for t in _as_list(context_terms) | |
| if t is not None and str(t).strip() | |
| ] | |
| grounded = (len(terms) == 0) or any(t in text.lower() for t in terms) | |
| checks = { | |
| "not_empty": len(text.strip()) > 0, | |
| "no_banned_terms": not contains_banned_terms(text), | |
| "length_ok": 15 <= len(text) <= 3000, | |
| "grounded": grounded, | |
| } | |
| checks["passed"] = all(checks.values()) | |
| return checks | |
| def _esc(s): | |
| return str(s).replace("&", "&").replace("<", "<").replace(">", ">") | |
| # --------------------------------------------------------------------------- # | |
| # Cached loaders | |
| # --------------------------------------------------------------------------- # | |
| def _resolve_file(filename, repo_type="dataset"): | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| return hf_hub_download(repo_id=DATASET_REPO, filename=filename, repo_type=repo_type) | |
| except Exception as e: | |
| if os.path.exists(filename): | |
| print(f"[load] Hub download failed for {filename} ({e}); using local copy.") | |
| return filename | |
| raise | |
| def load_artifacts(): | |
| meta = pd.read_csv(_resolve_file(F_METADATA)) | |
| emb = np.load(_resolve_file(F_EMB)) | |
| selected_model = DEFAULT_EMBED_MODEL | |
| try: | |
| info = json.load(open(_resolve_file(F_MODEL), "r", encoding="utf-8")) | |
| selected_model = info.get("selected_model", DEFAULT_EMBED_MODEL) | |
| except Exception as e: | |
| print(f"[load] model info JSON unavailable ({e}); using default embed model.") | |
| if len(meta) != len(emb): | |
| raise ValueError(f"Row mismatch: metadata={len(meta)} vs embeddings={len(emb)}") | |
| return meta, emb, selected_model | |
| def get_embed_model(): | |
| from sentence_transformers import SentenceTransformer | |
| _, _, selected_model = load_artifacts() | |
| print(f"[load] embedding model: {selected_model}") | |
| return SentenceTransformer(selected_model) | |
| def get_generator(): | |
| from transformers import pipeline | |
| try: | |
| return pipeline("text2text-generation", model=PRIMARY_GEN_MODEL, device=-1), PRIMARY_GEN_MODEL | |
| except Exception as e: | |
| print(f"[load] {PRIMARY_GEN_MODEL} failed ({e}); fallback {FALLBACK_GEN_MODEL}.") | |
| return pipeline("text2text-generation", model=FALLBACK_GEN_MODEL, device=-1), FALLBACK_GEN_MODEL | |
| def encode_query(text): | |
| return get_embed_model().encode([text], normalize_embeddings=True, convert_to_numpy=True)[0] | |
| # --------------------------------------------------------------------------- # | |
| # Image helpers | |
| # --------------------------------------------------------------------------- # | |
| def _img_data_uri(path, max_w): | |
| if not os.path.exists(path): | |
| return None | |
| data = None | |
| try: | |
| from PIL import Image | |
| im = Image.open(path).convert("RGB") | |
| if im.width > max_w: | |
| new_h = max(1, int(im.height * max_w / im.width)) | |
| im = im.resize((max_w, new_h)) | |
| buf = io.BytesIO() | |
| im.save(buf, format="JPEG", quality=82) | |
| data = buf.getvalue() | |
| except Exception: | |
| try: | |
| with open(path, "rb") as f: | |
| data = f.read() | |
| except Exception: | |
| return None | |
| return "data:image/jpeg;base64," + base64.b64encode(data).decode() | |
| def _norm_asset_name(value): | |
| value = str(value or "").strip().lower() | |
| value = value.replace("-", "_").replace(" ", "_") | |
| value = re.sub(r"[^a-z0-9_]+", "", value) | |
| value = re.sub(r"_+", "_", value).strip("_") | |
| return value | |
| def _asset_root_candidates(): | |
| candidates = ["assets", "./assets", "Assets", "assets:"] | |
| seen = [] | |
| for c in candidates: | |
| if c not in seen: | |
| seen.append(c) | |
| return [c for c in seen if os.path.isdir(c)] | |
| def _image_files_in_folder(folder_path): | |
| files = [] | |
| if not folder_path or not os.path.isdir(folder_path): | |
| return files | |
| for root, _, filenames in os.walk(folder_path): | |
| for filename in filenames: | |
| if filename.startswith("."): | |
| continue | |
| ext = os.path.splitext(filename)[1].lower() | |
| if ext in IMAGE_EXTS: | |
| files.append(os.path.join(root, filename)) | |
| return sorted(files) | |
| def _find_property_type_folder(property_type): | |
| mapped = PTYPE_TO_FOLDER.get(str(property_type), str(property_type)) | |
| wanted_names = { | |
| _norm_asset_name(mapped), | |
| _norm_asset_name(property_type), | |
| _norm_asset_name(str(property_type).replace("Apartment", "apartment")), | |
| } | |
| direct_candidates = [] | |
| for root in _asset_root_candidates(): | |
| direct_candidates.extend([ | |
| os.path.join(root, mapped), | |
| os.path.join(root, str(property_type)), | |
| os.path.join(root, _norm_asset_name(mapped)), | |
| os.path.join(root, _norm_asset_name(property_type)), | |
| os.path.join(root, str(property_type).lower().replace(" ", "_")), | |
| os.path.join(root, str(property_type).lower().replace(" ", "-")), | |
| ]) | |
| for path in direct_candidates: | |
| if os.path.isdir(path) and _image_files_in_folder(path): | |
| return path | |
| for root in _asset_root_candidates(): | |
| try: | |
| for name in os.listdir(root): | |
| path = os.path.join(root, name) | |
| if not os.path.isdir(path): | |
| continue | |
| norm = _norm_asset_name(name) | |
| if norm in wanted_names and _image_files_in_folder(path): | |
| return path | |
| except Exception: | |
| pass | |
| return None | |
| def _set_sort_key(path): | |
| name = os.path.basename(path).lower() | |
| m = re.search(r"set_(\d+)", name) | |
| if m: | |
| return (0, int(m.group(1)), name) | |
| return (1, 9999, name) | |
| def _list_set_folders(type_folder): | |
| if not type_folder or not os.path.isdir(type_folder): | |
| return [] | |
| out = [] | |
| for name in os.listdir(type_folder): | |
| path = os.path.join(type_folder, name) | |
| if os.path.isdir(path) and name.lower().startswith("set_"): | |
| if _image_files_in_folder(path): | |
| out.append(path) | |
| return sorted(out, key=_set_sort_key) | |
| def _uris_from_paths(paths): | |
| widths = [520, 160, 160] | |
| out = [] | |
| for path, w in zip(paths[:3], widths): | |
| uri = _img_data_uri(path, w) | |
| if uri: | |
| out.append(uri) | |
| return out | |
| def _pick_images_from_files(files, start_idx=0, limit=3): | |
| if not files: | |
| return [] | |
| start_idx = start_idx % len(files) | |
| ordered = files[start_idx:] + files[:start_idx] | |
| return ordered[:limit] | |
| def listing_images(property_type, listing_id): | |
| """ | |
| Default fallback: deterministic images by property type and listing_id. | |
| Used when we don't pre-assign a unique set at the search-result level. | |
| """ | |
| type_folder = _find_property_type_folder(property_type) | |
| if type_folder: | |
| set_folders = _list_set_folders(type_folder) | |
| if set_folders: | |
| try: | |
| idx = int(float(listing_id)) % len(set_folders) | |
| except Exception: | |
| idx = 0 | |
| files = _image_files_in_folder(set_folders[idx]) | |
| uris = _uris_from_paths(files) | |
| if uris: | |
| return uris | |
| files = _image_files_in_folder(type_folder) | |
| try: | |
| start = int(float(listing_id)) % max(len(files), 1) | |
| except Exception: | |
| start = 0 | |
| selected = _pick_images_from_files(files, start, limit=3) | |
| uris = _uris_from_paths(selected) | |
| if uris: | |
| return uris | |
| all_files = [] | |
| for root in _asset_root_candidates(): | |
| all_files.extend(_image_files_in_folder(root)) | |
| all_files = sorted(set(all_files)) | |
| try: | |
| start = int(float(listing_id)) % max(len(all_files), 1) | |
| except Exception: | |
| start = 0 | |
| selected = _pick_images_from_files(all_files, start, limit=3) | |
| return _uris_from_paths(selected) | |
| def hero_data_uri(): | |
| for root in _asset_root_candidates(): | |
| hero_folder = os.path.join(root, "hero") | |
| for ext in IMAGE_EXTS: | |
| uri = _img_data_uri(os.path.join(hero_folder, f"hero_home{ext}"), 1400) | |
| if uri: | |
| return uri | |
| hero_files = _image_files_in_folder(hero_folder) | |
| if hero_files: | |
| return _img_data_uri(hero_files[0], 1400) | |
| for root in _asset_root_candidates(): | |
| files = _image_files_in_folder(root) | |
| if files: | |
| return _img_data_uri(files[0], 1400) | |
| return None | |
| # --------------------------------------------------------------------------- # | |
| # Recommendation logic (filters BEFORE ranking) | |
| # --------------------------------------------------------------------------- # | |
| def recommend_properties(user_query, top_k=4, filters=None): | |
| meta, emb, _ = load_artifacts() | |
| mask = np.ones(len(meta), dtype=bool) | |
| if filters: | |
| if filters.get("city"): | |
| mask &= meta["city"].isin(_as_list(filters["city"])).values | |
| if filters.get("property_type"): | |
| mask &= meta["property_type"].isin(_as_list(filters["property_type"])).values | |
| if filters.get("max_price") is not None: | |
| mask &= (meta["price_million_nis"] <= float(filters["max_price"])).values | |
| if filters.get("min_rooms") is not None: | |
| mask &= (meta["rooms"] >= float(filters["min_rooms"])).values | |
| idxs = np.where(mask)[0] | |
| if len(idxs) == 0: | |
| return pd.DataFrame() | |
| qv = encode_query(user_query) | |
| sims = emb[idxs] @ qv | |
| order = np.argsort(-sims)[:int(top_k)] | |
| sel = idxs[order] | |
| cols = [c for c in REC_COLUMNS[2:] if c in meta.columns] | |
| out = meta.iloc[sel][cols].copy() | |
| out.insert(0, "similarity_score", np.round(sims[order], 4)) | |
| out.insert(0, "rank", range(1, len(out) + 1)) | |
| return out.reset_index(drop=True) | |
| # --------------------------------------------------------------------------- # | |
| # Context + generation | |
| # --------------------------------------------------------------------------- # | |
| def _amenities_list(row): | |
| return [ | |
| ("Elevator", to_bool(row.get("has_elevator"))), | |
| ("Parking", to_bool(row.get("has_parking"))), | |
| ("Balcony", to_bool(row.get("has_balcony"))), | |
| ("Renovated", to_bool(row.get("renovated"))), | |
| ("Near schools", to_bool(row.get("near_schools"))), | |
| ] | |
| def _amenities_text(row): | |
| return ", ".join([ | |
| "elevator" if to_bool(row.get("has_elevator")) else "no elevator", | |
| "parking" if to_bool(row.get("has_parking")) else "no parking", | |
| "balcony" if to_bool(row.get("has_balcony")) else "no balcony", | |
| "renovated" if to_bool(row.get("renovated")) else "not renovated", | |
| "near schools" if to_bool(row.get("near_schools")) else "not near schools", | |
| ]) | |
| def build_listing_context(rec_df, max_items=3): | |
| meta, _, _ = load_artifacts() | |
| ref = meta.set_index("listing_id") | |
| lines = [] | |
| for _, row in rec_df.head(max_items).iterrows(): | |
| lid = int(row["listing_id"]) | |
| rooms = row["rooms"] | |
| rooms = int(rooms) if float(rooms).is_integer() else rooms | |
| owner = row.get("owner_name") | |
| if (owner is None or pd.isna(owner)) and lid in ref.index and "owner_name" in ref.columns: | |
| owner = ref.loc[lid, "owner_name"] | |
| line = ( | |
| f"Rank {int(row['rank'])} | listing_id {lid}: " | |
| f"{row['property_type']} in {row['neighborhood']}, {row['city']}; " | |
| f"{rooms} rooms; {int(row['size_sqm'])} sqm; " | |
| f"{float(row['price_million_nis']):.2f} million NIS; " | |
| f"target audience: {row['target_audience']}; " | |
| f"amenities: {_amenities_text(row)}; " | |
| f"{int(row['distance_to_transport'])} minutes to public transport; " | |
| f"owner: {owner}." | |
| ) | |
| if lid in ref.index and "owner_message_template" in ref.columns and pd.notna(ref.loc[lid, "owner_message_template"]): | |
| line += f" Owner message template: {ref.loc[lid, 'owner_message_template']}" | |
| lines.append(line) | |
| return "\n".join(lines) | |
| def generate_buyer_summary(user_query, rec_df, max_items=3): | |
| pipe, _ = get_generator() | |
| context = build_listing_context(rec_df, max_items=max_items) | |
| prompt = ( | |
| "You are HomeMatch, a direct owner-to-buyer real-estate assistant.\n" | |
| "Write a short professional buyer summary in English.\n" | |
| "Explain why the top properties match the buyer request.\n" | |
| "Use 2-4 short bullet points.\n" | |
| "Use ONLY the facts provided.\n" | |
| "Do NOT invent details.\n" | |
| "Do NOT mention brokers, agents, agencies, or realtors.\n\n" | |
| f"BUYER REQUEST:\n{user_query}\n\n" | |
| f"FACTS:\n{context}\n\n" | |
| "ANSWER:" | |
| ) | |
| out = pipe(prompt, max_new_tokens=180, do_sample=False)[0]["generated_text"].strip() | |
| return clean_banned_terms(out) if contains_banned_terms(out) else out | |
| def generate_owner_inquiry(user_query, top_listing): | |
| pipe, _ = get_generator() | |
| owner = top_listing.get("owner_name") | |
| context = build_listing_context(pd.DataFrame([top_listing]), max_items=1) | |
| prompt = ( | |
| "You are helping a buyer contact a property owner directly on HomeMatch.\n" | |
| "Write a short polite message in English from the buyer to the owner" | |
| + (f" named {owner}" if owner is not None and not pd.isna(owner) else "") + ".\n" | |
| "Mention the property briefly and ask to schedule a viewing or receive more details.\n" | |
| "Use ONLY the facts provided.\n" | |
| "Do NOT invent details.\n" | |
| "Do NOT mention brokers, agents, agencies, or realtors.\n\n" | |
| f"BUYER REQUEST:\n{user_query}\n\n" | |
| f"FACTS:\n{context}\n\n" | |
| "MESSAGE:" | |
| ) | |
| out = pipe(prompt, max_new_tokens=140, do_sample=False)[0]["generated_text"].strip() | |
| return clean_banned_terms(out) if contains_banned_terms(out) else out | |
| # --------------------------------------------------------------------------- # | |
| # Card image assignment per search result set | |
| # --------------------------------------------------------------------------- # | |
| def assign_images_for_results(rec_df): | |
| """ | |
| Pre-assign images for the current result set. | |
| Goal: | |
| - avoid repeating the same set of images within the same search result list | |
| - keep assignment stable and by property type | |
| - if there are 4 set folders, the first 4 cards of that property type get different sets | |
| """ | |
| assigned = {} | |
| for ptype, group in rec_df.groupby("property_type", sort=False): | |
| type_folder = _find_property_type_folder(ptype) | |
| if not type_folder: | |
| continue | |
| set_folders = _list_set_folders(type_folder) | |
| if not set_folders: | |
| continue | |
| group_indexes = list(group.index) | |
| try: | |
| offset = int(float(group.iloc[0]["listing_id"])) % len(set_folders) | |
| except Exception: | |
| offset = 0 | |
| rotated = set_folders[offset:] + set_folders[:offset] | |
| for j, idx in enumerate(group_indexes): | |
| # unique set assignment while available | |
| if j < len(rotated): | |
| files = _image_files_in_folder(rotated[j]) | |
| uris = _uris_from_paths(files) | |
| if uris: | |
| assigned[idx] = uris | |
| return assigned | |
| # --------------------------------------------------------------------------- # | |
| # Property cards | |
| # --------------------------------------------------------------------------- # | |
| def _card_html(row, imgs=None): | |
| if imgs is None: | |
| imgs = listing_images(row["property_type"], row["listing_id"]) | |
| if imgs: | |
| main = f'<img class="hm-main" src="{imgs[0]}" alt="property image">' | |
| thumbs = "".join(f'<img class="hm-thumb" src="{u}" alt="thumb">' for u in imgs[1:3]) | |
| thumbs_html = f'<div class="hm-thumbs">{thumbs}</div>' if thumbs else "" | |
| else: | |
| main = f'<div class="hm-main hm-ph">{_esc(row["property_type"])}</div>' | |
| thumbs_html = "" | |
| chips = "".join( | |
| f'<span class="hm-chip {"on" if ok else "off"}">{("✓" if ok else "✕")} {label}</span>' | |
| for label, ok in _amenities_list(row) | |
| ) | |
| rooms = row["rooms"] | |
| rooms = int(rooms) if float(rooms).is_integer() else rooms | |
| owner = row.get("owner_name") | |
| owner_html = ( | |
| f'<div class="hm-owner">Owner: {_esc(owner)}</div>' | |
| if owner is not None and not pd.isna(owner) else "" | |
| ) | |
| return f""" | |
| <div class="hm-card"> | |
| <div class="hm-imgwrap"> | |
| {main} | |
| <div class="hm-badge">★ {float(row['similarity_score']):.3f}</div> | |
| {thumbs_html} | |
| </div> | |
| <div class="hm-body"> | |
| <div class="hm-rank">#{int(row['rank'])} · {_esc(row['property_type'])}</div> | |
| <div class="hm-title">{_esc(row['neighborhood'])}, {_esc(row['city'])}</div> | |
| <div class="hm-price">{float(row['price_million_nis']):.2f} <span>M NIS</span></div> | |
| <div class="hm-specs"> | |
| <span>{rooms} rooms</span><span>·</span> | |
| <span>{int(row['size_sqm'])} m²</span><span>·</span> | |
| <span>{int(row['distance_to_transport'])} min to transit</span> | |
| </div> | |
| <div class="hm-aud">{_esc(row['target_audience'])}</div> | |
| <div class="hm-chips">{chips}</div> | |
| {owner_html} | |
| </div> | |
| </div> | |
| """ | |
| def build_cards_html(rec_df): | |
| preassigned = assign_images_for_results(rec_df) | |
| cards = "" | |
| for idx, row in rec_df.iterrows(): | |
| imgs = preassigned.get(idx) | |
| cards += _card_html(row, imgs=imgs) | |
| note = ( | |
| '<div class="hm-note">' | |
| 'Property images are illustrative and assigned by property type. ' | |
| 'Structured filters are applied before semantic ranking.' | |
| '</div>' | |
| ) | |
| return f'<div class="hm-grid">{cards}</div>{note}' | |
| # --------------------------------------------------------------------------- # | |
| # Gradio handler | |
| # --------------------------------------------------------------------------- # | |
| DISPLAY_COLS = [ | |
| "rank", "similarity_score", "listing_id", "city", "neighborhood", | |
| "property_type", "rooms", "size_sqm", "price_million_nis", | |
| "target_audience", "distance_to_transport", "owner_name" | |
| ] | |
| EMPTY_QC = pd.DataFrame(columns=[ | |
| "output", "not_empty", "no_banned_terms", | |
| "length_ok", "grounded", "passed" | |
| ]) | |
| def _msg_html(text): | |
| return f'<div class="hm-msg">{_esc(text)}</div>' | |
| def run_homematch(user_query, city, property_type, max_price, min_rooms, top_k): | |
| user_query = (user_query or "").strip() | |
| if not user_query: | |
| return ( | |
| _msg_html("Please describe what you are looking for to get recommendations."), | |
| pd.DataFrame(), | |
| "Please enter what you are looking for.", | |
| "", | |
| EMPTY_QC | |
| ) | |
| filters = {} | |
| if city and city != "Any": | |
| filters["city"] = city | |
| if property_type and property_type != "Any": | |
| filters["property_type"] = property_type | |
| if max_price and float(max_price) > 0: | |
| filters["max_price"] = float(max_price) | |
| if min_rooms and float(min_rooms) > 0: | |
| filters["min_rooms"] = float(min_rooms) | |
| try: | |
| recs = recommend_properties(user_query, top_k=int(top_k), filters=filters) | |
| except Exception as e: | |
| return ( | |
| _msg_html(f"Could not load recommendation data: {e}"), | |
| pd.DataFrame(), | |
| f"Could not load recommendation data: {e}", | |
| "", | |
| EMPTY_QC | |
| ) | |
| if recs.empty: | |
| return ( | |
| _msg_html("No listings match those filters. Try widening the city, price, or rooms."), | |
| pd.DataFrame(), | |
| "No listings match those filters. Try widening the city, price, or rooms.", | |
| "", | |
| EMPTY_QC | |
| ) | |
| cards = build_cards_html(recs) | |
| table = recs[[c for c in DISPLAY_COLS if c in recs.columns]].copy() | |
| top = recs.iloc[0] | |
| ground_terms = [top.get("city"), top.get("neighborhood"), top.get("property_type")] | |
| try: | |
| summary = generate_buyer_summary(user_query, recs, max_items=3) | |
| inquiry = generate_owner_inquiry(user_query, top) | |
| qc = pd.DataFrame([ | |
| {"output": "buyer_summary", **generation_quality_check(summary, ground_terms)}, | |
| {"output": "owner_inquiry", **generation_quality_check(inquiry, ground_terms)}, | |
| ]) | |
| except Exception as e: | |
| summary = ( | |
| "Recommendations are ready above, but text generation is currently " | |
| f"unavailable ({type(e).__name__}). Please try again shortly." | |
| ) | |
| inquiry = "" | |
| qc = EMPTY_QC | |
| return cards, table, summary, inquiry, qc | |
| # --------------------------------------------------------------------------- # | |
| # Styling | |
| # --------------------------------------------------------------------------- # | |
| CSS = """ | |
| #hm-hero { | |
| border-radius:18px; | |
| overflow:hidden; | |
| position:relative; | |
| margin-bottom:14px; | |
| min-height:230px; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| background:#1f2937; | |
| background-size:cover; | |
| background-position:center; | |
| color:#fff; | |
| } | |
| #hm-hero .hm-hero-overlay { | |
| position:absolute; | |
| inset:0; | |
| background:linear-gradient(180deg, rgba(17,24,39,.35), rgba(17,24,39,.72)); | |
| } | |
| #hm-hero .hm-hero-inner { | |
| position:relative; | |
| text-align:center; | |
| padding:34px 18px; | |
| } | |
| #hm-hero h1 { | |
| font-size:2.3rem; | |
| margin:0; | |
| font-weight:800; | |
| letter-spacing:.3px; | |
| } | |
| #hm-hero p { | |
| margin:.5rem 0 0; | |
| font-size:1.06rem; | |
| opacity:.95; | |
| } | |
| .hm-grid { | |
| display:grid; | |
| grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); | |
| gap:16px; | |
| margin-top:6px; | |
| } | |
| .hm-card { | |
| border:1px solid #e6e8ec; | |
| border-radius:16px; | |
| overflow:hidden; | |
| background:#fff; | |
| box-shadow:0 2px 10px rgba(16,24,40,.06); | |
| transition:transform .12s, box-shadow .12s; | |
| } | |
| .hm-card:hover { | |
| transform:translateY(-3px); | |
| box-shadow:0 10px 24px rgba(16,24,40,.12); | |
| } | |
| .hm-imgwrap { | |
| position:relative; | |
| background:#eef1f5; | |
| } | |
| .hm-main { | |
| width:100%; | |
| height:190px; | |
| object-fit:cover; | |
| display:block; | |
| } | |
| .hm-main.hm-ph { | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| color:#8a94a6; | |
| font-weight:600; | |
| letter-spacing:.4px; | |
| background:linear-gradient(135deg,#e9eef5,#dfe6f0); | |
| } | |
| .hm-badge { | |
| position:absolute; | |
| top:10px; | |
| right:10px; | |
| background:rgba(17,24,39,.82); | |
| color:#fff; | |
| font-size:.78rem; | |
| padding:3px 9px; | |
| border-radius:999px; | |
| } | |
| .hm-thumbs { | |
| position:absolute; | |
| bottom:8px; | |
| left:8px; | |
| display:flex; | |
| gap:6px; | |
| } | |
| .hm-thumb { | |
| width:52px; | |
| height:40px; | |
| object-fit:cover; | |
| border-radius:7px; | |
| border:2px solid #fff; | |
| box-shadow:0 1px 4px rgba(0,0,0,.25); | |
| } | |
| .hm-body { | |
| padding:13px 15px 15px; | |
| } | |
| .hm-rank { | |
| font-size:.74rem; | |
| text-transform:uppercase; | |
| letter-spacing:.6px; | |
| color:#6b7280; | |
| } | |
| .hm-title { | |
| font-size:1.12rem; | |
| font-weight:750; | |
| margin:.15rem 0 .1rem; | |
| color:#111827; | |
| } | |
| .hm-price { | |
| font-size:1.18rem; | |
| font-weight:800; | |
| color:#1d4ed8; | |
| } | |
| .hm-price span { | |
| font-size:.78rem; | |
| font-weight:600; | |
| color:#6b7280; | |
| } | |
| .hm-specs { | |
| display:flex; | |
| gap:7px; | |
| flex-wrap:wrap; | |
| color:#374151; | |
| font-size:.9rem; | |
| margin:.35rem 0; | |
| } | |
| .hm-specs span { | |
| white-space:nowrap; | |
| } | |
| .hm-aud { | |
| font-size:.85rem; | |
| color:#6b7280; | |
| font-style:italic; | |
| margin-bottom:.5rem; | |
| } | |
| .hm-chips { | |
| display:flex; | |
| flex-wrap:wrap; | |
| gap:6px; | |
| } | |
| .hm-chip { | |
| font-size:.74rem; | |
| padding:3px 8px; | |
| border-radius:999px; | |
| border:1px solid #e5e7eb; | |
| } | |
| .hm-chip.on { | |
| background:#ecfdf5; | |
| color:#047857; | |
| border-color:#a7f3d0; | |
| } | |
| .hm-chip.off { | |
| background:#f9fafb; | |
| color:#9ca3af; | |
| } | |
| .hm-owner { | |
| margin-top:.55rem; | |
| font-size:.82rem; | |
| color:#374151; | |
| } | |
| .hm-note { | |
| margin:14px 2px; | |
| font-size:.82rem; | |
| color:#6b7280; | |
| font-style:italic; | |
| } | |
| .hm-msg { | |
| padding:26px; | |
| text-align:center; | |
| color:#374151; | |
| background:#f8fafc; | |
| border:1px dashed #cbd5e1; | |
| border-radius:14px; | |
| font-size:1rem; | |
| } | |
| """ | |
| def hero_html(): | |
| uri = hero_data_uri() | |
| bg = f"background-image:url('{uri}');" if uri else "" | |
| return f""" | |
| <div id="hm-hero" style="{bg}"> | |
| <div class="hm-hero-overlay"></div> | |
| <div class="hm-hero-inner"> | |
| <h1>HomeMatch AI</h1> | |
| <p>AI-powered property recommendations with embeddings and grounded generation</p> | |
| </div> | |
| </div> | |
| """ | |
| def _dropdown_choices(): | |
| try: | |
| meta, _, _ = load_artifacts() | |
| cities = ["Any"] + sorted(meta["city"].dropna().unique().tolist()) | |
| ptypes = ["Any"] + sorted(meta["property_type"].dropna().unique().tolist()) | |
| except Exception as e: | |
| print(f"[ui] could not preload dropdowns ({e}); using 'Any' only.") | |
| cities, ptypes = ["Any"], ["Any"] | |
| return cities, ptypes | |
| CITY_CHOICES, PTYPE_CHOICES = _dropdown_choices() | |
| QUICK_STARTERS = [ | |
| [ | |
| "I need a 4 room apartment in Tel Aviv for a young couple, close to public transport.", | |
| "Tel Aviv", "Any", 0, 4, 4 | |
| ], | |
| [ | |
| "Family looking for a large renovated home in Jerusalem near schools.", | |
| "Jerusalem", "Any", 0, 4, 4 | |
| ], | |
| [ | |
| "Student looking for an affordable studio in Be'er Sheva near transportation.", | |
| "Be'er Sheva", "Studio", 0, 0, 4 | |
| ], | |
| ] | |
| with gr.Blocks(title="HomeMatch AI", theme=gr.themes.Soft(), css=CSS) as demo: | |
| gr.HTML(hero_html()) | |
| with gr.Row(): | |
| query_in = gr.Textbox( | |
| label="What are you looking for?", | |
| lines=2, | |
| scale=4, | |
| placeholder="e.g. renovated 4-room apartment in Tel Aviv near public transport for a young couple" | |
| ) | |
| run_btn = gr.Button("🔍 Find Matching Homes", variant="primary", scale=1) | |
| with gr.Row(equal_height=False): | |
| with gr.Column(scale=1, min_width=240): | |
| gr.Markdown("#### Filters") | |
| city_in = gr.Dropdown(CITY_CHOICES, value="Any", label="City") | |
| ptype_in = gr.Dropdown(PTYPE_CHOICES, value="Any", label="Property type") | |
| price_in = gr.Slider(0, 10, value=0, step=0.25, label="Max price (million NIS, 0 = any)") | |
| rooms_in = gr.Slider(0, 8, value=0, step=0.5, label="Min rooms (0 = any)") | |
| topk_in = gr.Slider(1, 4, value=4, step=1, label="Number of results (top_k)") | |
| gr.Markdown("#### Quick starters") | |
| gr.Examples( | |
| examples=QUICK_STARTERS, | |
| inputs=[query_in, city_in, ptype_in, price_in, rooms_in, topk_in], | |
| label="Click an example to fill the form", | |
| ) | |
| with gr.Column(scale=3): | |
| cards_out = gr.HTML(label="Recommended properties") | |
| with gr.Accordion("Recommendation table", open=False): | |
| rec_out = gr.Dataframe(interactive=False, wrap=True) | |
| with gr.Accordion("Buyer summary", open=True): | |
| summary_out = gr.Markdown() | |
| with gr.Accordion("Owner inquiry message", open=True): | |
| inquiry_out = gr.Textbox(label="", lines=6, show_copy_button=True) | |
| with gr.Accordion("Safety & quality checks", open=False): | |
| qc_out = gr.Dataframe(interactive=False) | |
| run_btn.click( | |
| run_homematch, | |
| inputs=[query_in, city_in, ptype_in, price_in, rooms_in, topk_in], | |
| outputs=[cards_out, rec_out, summary_out, inquiry_out, qc_out], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True | |
| ) |