import os import json import re import pandas as pd import gradio as gr from inference_core import run_full_inference from fe_avm_bdo26 import location_map_dict from map_builder import generate_area_map_safe as generate_area_map, generate_full_bandung_map_safe as generate_full_bandung_map from chatbot_core import chat, EMPTY_STATE # ============================================================================= # GEMINI CLIENT # ============================================================================= client = None gemini_available = False try: from google import genai api_key = os.getenv("GEMINI_API_KEY") if api_key: client = genai.Client(api_key=api_key) gemini_available = True print("Gemini client initialized.") else: print("No GEMINI_API_KEY found. Using fallback parser only.") except Exception as e: print("Gemini init error:", e) client = None gemini_available = False print("GEMINI AVAILABLE:", gemini_available) print("CLIENT IS NONE:", client is None) print("ENV KEY FOUND:", os.getenv("GEMINI_API_KEY") is not None) # ============================================================================= # SAFE PARSERS # ============================================================================= def safe_float(x): if x is None or str(x).strip() == "": return None return float(x) def safe_int(x): if x is None or str(x).strip() == "": return None return int(float(x)) # ============================================================================= # FALLBACK PARSER # ============================================================================= def fallback_parse_notes(user_notes): if not user_notes: return {} text = user_notes.lower() parsed = {} # location parsing known_locations = sorted( location_map_dict.keys(), key=len, reverse=True ) for loc in known_locations: if loc.lower() in text: parsed["location"] = loc break m = re.search(r"(\d+)\s*bed", text) if m: parsed["bedroom_total"] = int(m.group(1)) m = re.search(r"(\d+)\s*bath", text) if m: parsed["bathroom_total"] = int(m.group(1)) m = re.search(r"(\d+)\s*car", text) if m: parsed["carport_total"] = int(m.group(1)) # budget parsing (more robust) budget_patterns = [ r"(?:budget|harga|max|under|below|dibawah|di bawah|sekitar|around)\s*(?:rp\s*)?(\d+(?:\.\d+)?)\s*(m|miliar|billion|jt|juta)?", r"(?:rp\s*)?(\d+(?:\.\d+)?)\s*(m|miliar|billion|jt|juta)\b" ] budget_val = None unit = "" for pattern in budget_patterns: m = re.search(pattern, text) if m: budget_val = float(m.group(1)) unit = (m.group(2) or "").lower() break if budget_val is not None: if unit in ["m", "miliar", "billion"]: budget_val *= 1000 elif unit in ["jt", "juta"]: budget_val = budget_val elif budget_val < 10: budget_val *= 1000 parsed["budget_million_idr"] = budget_val return parsed # ============================================================================= # GEMINI PARSER # ============================================================================= def parse_preferences_with_gemini(user_notes, active_client): if not user_notes: return {} fallback = fallback_parse_notes(user_notes) parsed = {} if active_client is None: return fallback try: prompt = f""" Extract housing preferences from this text. Return ONLY valid JSON. DO NOT explain anything. Keep concise. Exact location from Bandung where possible. Correct OBVIOUS typos and use right caps. e.g: sayap dago into Sayap Dago, daog Pakar into Dago Pakar. Budget rules: - if budget number is below 10, assume it is in billions - convert to million IDR - example: 1 = 1000, 1.5 = 1500, 9.9 = 9900 - words like under, below, max mean budget Use keys: location building_type bedroom_total bathroom_total carport_total budget_million_idr building_area_m2 land_area_m2 Text: {user_notes} """ model_candidates = [ "gemini-2.5-flash-lite" ] for model_name in model_candidates: try: response = active_client.models.generate_content( model=model_name, contents=prompt, config={ "max_output_tokens": 80, "temperature": 0.1, "top_p": 0.9 } ) if hasattr(response, "text") and response.text: clean_text = response.text.strip() else: clean_text = str(response).strip() clean_text = ( clean_text .replace("```json", "") .replace("```", "") .strip() ) gemini_parsed = json.loads(clean_text) if isinstance(gemini_parsed, dict): # Gemini first parsed.update(gemini_parsed) # fallback fills only missing values for k, v in fallback.items(): if k not in parsed or parsed[k] in [None, ""]: parsed[k] = v print(f"GEMINI PARSER SUCCESS: {model_name}") break except Exception as e: print(f"GEMINI PARSER FAILED {model_name}: {e}") return parsed if parsed else fallback except Exception as e: print("Gemini parse error:", e) return parsed # ============================================================================= # HTML FORMATTERS # ============================================================================= def make_clickable_links(df): if df is None or df.empty: return """
No listings found.
""" df = df.copy() pretty_cols = { "building_type": "Type", "location": "Location", "price_million_idr": "Price (IDR Mn)", "listing_summary": "Listing Summary" } df = df.rename(columns=pretty_cols) if "url" in df.columns: def make_short_clickable_link(x): if pd.isna(x) or str(x).strip() == "": return "" full_url = str(x).strip() # ensure full valid URL if not full_url.startswith("http"): full_url = "https://" + full_url # pretty display text display_text = full_url[:25] + "..." if len(full_url) > 25 else full_url return f'{display_text}' df["Link"] = df["url"].apply(make_short_clickable_link) df = df.drop(columns=["url"]) df["Listing Summary"] = df["Listing Summary"].apply( lambda x: str(x)[:90] + "..." if pd.notna(x) and len(str(x)) > 90 else x ) return df.to_html( escape=False, index=False, classes="listing-table", justify="left" ) def wrap_params_card(df): if df is None or df.empty: return "
No parameters available.
" row = df.iloc[0].to_dict() html_parts = ["
"] html_parts.append("
Parameters Used
") html_parts.append("
") for key, val in row.items(): show_val = "—" if pd.isna(val) else val html_parts.append( f"""
{key}
{show_val}
""" ) html_parts.append("
") return "".join(html_parts) # ============================================================================= # MAIN SMART VALUATION FUNCTION # ============================================================================= def predict_housing( user_notes, location, building_type, bedroom_total, bathroom_total, carport_total, building_area_m2, land_area_m2, budget_million_idr ): user_info = { "location": location, "building_type": building_type, "bedroom_total": safe_int(bedroom_total), "bathroom_total": safe_int(bathroom_total), "carport_total": safe_int(carport_total), "building_area_m2": safe_float(building_area_m2), "land_area_m2": safe_float(land_area_m2), "budget_million_idr": safe_float(budget_million_idr), } # treat untouched UI zeros as missing for key in ["bedroom_total", "bathroom_total", "carport_total"]: if user_info.get(key) == 0: user_info[key] = None parsed_notes = parse_preferences_with_gemini( user_notes, client ) print("PARSED NOTES:", parsed_notes) if user_notes and user_notes.strip(): for key, val in parsed_notes.items(): if user_info.get(key) in [None, "", 0]: if val not in [None, ""]: user_info[key] = val if user_notes and user_notes.strip(): user_info["notes"] = user_notes print("FINAL USER INFO:", user_info) result = run_full_inference( user_info, client=client ) params_df = pd.DataFrame([result["params_used"]]) params_card_html = wrap_params_card(params_df) pred = result["prediction"] estimate_md = f"""
House Price Estimate
IDR {pred['Predicted Price (IDR Mn)']:.0f} Mn
Lower Bound
IDR {pred['Lower Range']:.0f} Mn
Upper Bound
IDR {pred['Upper Range']:.0f} Mn
""" map_html = generate_area_map( location=user_info.get("location"), price_mid=round(pred["Predicted Price (IDR Mn)"], -1), price_lo=round(pred["Lower Range"], -1), price_hi=round(pred["Upper Range"], -1) ) comparison_intro_text = ( f"### If you like **{user_info.get('location')}**, " f"you may also want to consider:" ) comparison_df = result["comparison_table"].copy() comparison_df = comparison_df.rename( columns={"Desirability Score": "Desirability Score"} ) target_title = ( f"### Example listings in **{user_info.get('location')}**" ) alt_title = "### Example listings in **alternative areas**" target_html = make_clickable_links(result["target_listings"]) rec_html = make_clickable_links(result["recommended_listings"]) target_block = f"{target_title}\n{target_html}" alt_block = f"{alt_title}\n{rec_html}" summary_md = f"""
{result["summary"]}
""" return ( params_card_html, estimate_md, map_html, result["market_summary"], comparison_intro_text, comparison_df, target_block, alt_block, summary_md ) # ============================================================================= # ABOUT TAB HELPERS # ============================================================================= def get_booklet_path(): candidates = [ "booklet_rmt52_boni.pdf", "RMT52_product_booklet.pdf", "RMT52 Booklet.pdf", "RMT52_booklet.pdf", "company_booklet.pdf", "product_booklet.pdf" ] for path in candidates: if os.path.exists(path): return path return None BOOKLET_PATH = get_booklet_path() # ============================================================================= # UI # ============================================================================= custom_css = """ :root { --rmt-main: #330066; --rmt-text: #142B4D; --rmt-secondary: #4D6A92; --rmt-accent: #660033; --rmt-bg: #FFFFFF; --rmt-soft: #FAF8F4; --rmt-line: #E7EDF3; } .gradio-container { max-width: 1380px !important; margin: auto; background: var(--rmt-bg); } .block-title { background: var(--rmt-main); color: white !important; padding: 14px 18px; border-radius: 14px; font-weight: 700; margin-bottom: 14px; font-size: 18px; } .section-title { color: var(--rmt-text); font-weight: 700; margin-top: 8px; margin-bottom: 6px; } .soft-note { color: var(--rmt-secondary); font-size: 14px; margin-bottom: 10px; } .info-card { background: white; border: 1px solid var(--rmt-line); border-radius: 16px; padding: 16px; box-shadow: 0 2px 8px rgba(20,43,77,0.04); } .info-card-title { color: var(--rmt-text); font-size: 17px; font-weight: 700; margin-bottom: 12px; } .param-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .param-item { background: var(--rmt-soft); border: 1px solid var(--rmt-line); border-radius: 12px; padding: 10px 12px; } .param-label { color: var(--rmt-secondary); font-size: 12px; margin-bottom: 4px; } .param-value { color: var(--rmt-text); font-weight: 600; font-size: 14px; } .estimate-card { background: white; border: 1px solid var(--rmt-line); border-top: 6px solid var(--rmt-main); border-radius: 18px; padding: 18px; box-shadow: 0 3px 10px rgba(20,43,77,0.06); } .estimate-title { color: var(--rmt-text); font-size: 16px; font-weight: 700; margin-bottom: 8px; } .estimate-main { color: var(--rmt-main); font-size: 30px; font-weight: 800; margin-bottom: 14px; } .estimate-subgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .estimate-subitem { background: var(--rmt-soft); border-radius: 12px; padding: 12px; border: 1px solid var(--rmt-line); } .estimate-sub-label { color: var(--rmt-secondary); font-size: 12px; margin-bottom: 4px; } .estimate-sub-value { color: var(--rmt-text); font-weight: 700; font-size: 15px; } .leaflet-control-container { z-index: 9999 !important; } .leaflet-top, .leaflet-right { z-index: 9999 !important; } .leaflet-control-layers { z-index: 9999 !important; background: white !important; border-radius: 12px !important; } .summary-card { background: #ffffff; border-left: 6px solid var(--rmt-accent); border-radius: 12px; border-top: 1px solid var(--rmt-line); border-right: 1px solid var(--rmt-line); border-bottom: 1px solid var(--rmt-line); padding: 16px; color: var(--rmt-text); line-height: 1.6; } .listing-table { width: 100%; border-collapse: collapse; border-radius: 12px; overflow: hidden; } .listing-table th { background: var(--rmt-secondary); color: white; padding: 10px; text-align: left; white-space: normal !important; } .listing-table td { padding: 10px; border-bottom: 1px solid var(--rmt-line); white-space: normal !important; word-wrap: break-word !important; overflow-wrap: anywhere !important; color: var(--rmt-text); background: white; } .listing-empty { padding: 12px; border: 1px dashed var(--rmt-line); border-radius: 12px; color: var(--rmt-secondary); background: var(--rmt-soft); } footer { text-align: center; color: var(--rmt-secondary); font-size: 12px; margin-top: 18px; } button.primary, button[variant="primary"] { background: var(--rmt-accent) !important; border: none !important; } .gr-tab-nav button.selected { color: var(--rmt-main) !important; border-color: var(--rmt-main) !important; } h1, h2, h3, h4 { color: var(--rmt-text); } /* FULL MAP FIX */ #full-map iframe, #area-map iframe { height: 780px !important; min-height: 780px !important; width: 100% !important; border-radius: 18px; } #full-map, #area-map { min-height: 780px !important; overflow: visible !important; } .leaflet-control-container { z-index: 99999 !important; overflow: visible !important; } .leaflet-top, .leaflet-right, .leaflet-control { z-index: 99999 !important; overflow: visible !important; } .leaflet-control-layers { z-index: 99999 !important; overflow: visible !important; max-height: none !important; } iframe { overflow: visible !important; } """ with gr.Blocks( title="RMT52", theme=gr.themes.Soft(), css=custom_css ) as demo: gr.Markdown(""" # RMT52 ### Bandung Housing Decision Support Tool · 2026 """) with gr.Tabs(): # ========================================================================= # TAB 1 - SMART VALUATION # ========================================================================= with gr.Tab("🏡 Smart Valuation"): with gr.Row(): # ------------------------------------------------- # LEFT COLUMN # ------------------------------------------------- with gr.Column(scale=1): gr.HTML( "
What are you looking for today?
" ) user_notes = gr.Textbox( lines=4, label="", placeholder=( "I’m looking for a 4-bedroom house in Riau " "with a budget around 1.5B, preferably near schools." ) ) chat_parse_btn = gr.Button( "Find With Chat", variant="primary" ) gr.Markdown( "
OR fill in the form below
" ) gr.HTML("
House Price Estimate
") location = gr.Textbox( label="Preferred Location", placeholder="e.g. Riau" ) building_type = gr.Dropdown( choices=["Rumah", "Apartemen", "Ruko"], label="Building Type", value="Rumah" ) with gr.Row(): land_area_m2 = gr.Textbox( label="Land Area (m²)", placeholder="Optional" ) building_area_m2 = gr.Textbox( label="Building Area (m²)", placeholder="Optional" ) bedroom_total = gr.Number( label="Bedrooms", precision=0 ) bathroom_total = gr.Number( label="Bathrooms", precision=0 ) carport_total = gr.Number( label="Carport", precision=0 ) budget_million_idr = gr.Textbox( label="Budget (IDR Mn)", placeholder="Optional" ) run_btn = gr.Button( "Estimate Price", variant="primary" ) params_used_output = gr.HTML() # ------------------------------------------------- # RIGHT COLUMN # ------------------------------------------------- with gr.Column(scale=2): estimate_output = gr.HTML() map_output = gr.HTML(elem_id="area-map") gr.Markdown("# Outputs") gr.Markdown("## Market Context") market_summary_output = gr.Markdown() gr.Markdown("## Area Comparison") comparison_intro = gr.Markdown() comparison_output = gr.Dataframe(wrap=True, label="") target_listing_output = gr.HTML() rec_listing_output = gr.HTML() gr.Markdown("## Summary") summary_output = gr.HTML() run_btn.click( fn=predict_housing, inputs=[ user_notes, location, building_type, bedroom_total, bathroom_total, carport_total, building_area_m2, land_area_m2, budget_million_idr ], outputs=[ params_used_output, estimate_output, map_output, market_summary_output, comparison_intro, comparison_output, target_listing_output, rec_listing_output, summary_output ] ) chat_parse_btn.click( fn=predict_housing, inputs=[ user_notes, location, building_type, bedroom_total, bathroom_total, carport_total, building_area_m2, land_area_m2, budget_million_idr ], outputs=[ params_used_output, estimate_output, map_output, market_summary_output, comparison_intro, comparison_output, target_listing_output, rec_listing_output, summary_output ] ) # ========================================================================= # TAB 2 - CHATBOT # ========================================================================= with gr.Tab("💬 HomieRMT Chatbot"): gr.Markdown(""" ### 💬 HomieRMT Assistant Ask naturally about: - property price estimates - similar areas - Bandung market context """) chat_history_box = gr.Markdown( value="**HomieRMT:** Hi! Ask me anything about Bandung housing.\n" ) chat_input = gr.Textbox( label="Message", placeholder="Ask about housing in Bandung...", lines=2 ) send_btn = gr.Button("Send", variant="primary") history_state = gr.State([]) chat_state = gr.State(EMPTY_STATE) def conversational_chat(msg, transcript, history, state): updated_history, new_state = chat(msg, history, state) latest_reply = updated_history[-1]["content"] transcript += f"\n\n**You:** {msg}\n\n**HomieRMT:** {latest_reply}" return transcript, updated_history, new_state send_btn.click( fn=conversational_chat, inputs=[chat_input, chat_history_box, history_state, chat_state], outputs=[chat_history_box, history_state, chat_state] ) chat_input.submit( fn=conversational_chat, inputs=[chat_input, chat_history_box, history_state, chat_state], outputs=[chat_history_box, history_state, chat_state] ) # ========================================================================= # TAB 3 - FULL BANDUNG MAP # ========================================================================= with gr.Tab("🗺️ Bandung Market Map"): gr.Markdown(""" ### Bandung Market Map Explore city-wide housing patterns, facilities, and accessibility layers. Toggle map layers from the control panel on the map. """) load_full_map_btn = gr.Button( "Load Bandung Market Map", variant="primary" ) full_map_display = gr.HTML( value="Click the button to load map.", elem_id="full-map" ) load_full_map_btn.click( fn=generate_full_bandung_map, outputs=full_map_display ) # ========================================================================= # TAB 4 - ABOUT # ========================================================================= with gr.Tab("ℹ️ About RMT52"): gr.Markdown(""" ## RMT52 RMT52 combines smart valuation, neighborhood intelligence, and interactive market mapping to support better housing decisions in Bandung. ### What it does - smart property valuation - comparable area recommendations - market context and listing examples - interactive Bandung market map - conversational advisor support ### Methodology RMT52 is powered by a calibrated valuation and recommendation stack using location-aware feature engineering and structured market signals from Bandung housing listings. ### Product Booklet Download the product booklet for more information, use cases, and methodology. """) if BOOKLET_PATH is not None: gr.File( value=BOOKLET_PATH, label="📘 Download the RMT52 Product Booklet" ) else: gr.Markdown( "_Booklet file not found yet. Add your PDF to the app folder and name it one of the expected booklet filenames._" ) gr.Markdown( "" ) if __name__ == "__main__": demo.queue() demo.launch(server_name="0.0.0.0")