House / app.py
nandutt's picture
Update app.py
c9c6a70 verified
Raw History Blame Contribute Delete
26.8 kB
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 """
<div class="listing-empty">
No listings found.
</div>
"""
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'<a href="{full_url}" target="_blank">{display_text}</a>'
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 "<div class='info-card'>No parameters available.</div>"
row = df.iloc[0].to_dict()
html_parts = ["<div class='info-card'>"]
html_parts.append("<div class='info-card-title'>Parameters Used</div>")
html_parts.append("<div class='param-grid'>")
for key, val in row.items():
show_val = "—" if pd.isna(val) else val
html_parts.append(
f"""
<div class="param-item">
<div class="param-label">{key}</div>
<div class="param-value">{show_val}</div>
</div>
"""
)
html_parts.append("</div></div>")
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"""
<div class="estimate-card">
<div class="estimate-title">House Price Estimate</div>
<div class="estimate-main">IDR {pred['Predicted Price (IDR Mn)']:.0f} Mn</div>
<div class="estimate-subgrid">
<div class="estimate-subitem">
<div class="estimate-sub-label">Lower Bound</div>
<div class="estimate-sub-value">IDR {pred['Lower Range']:.0f} Mn</div>
</div>
<div class="estimate-subitem">
<div class="estimate-sub-label">Upper Bound</div>
<div class="estimate-sub-value">IDR {pred['Upper Range']:.0f} Mn</div>
</div>
</div>
</div>
"""
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"""
<div class="summary-card">
{result["summary"]}
</div>
"""
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(
"<div class='section-title' style='font-size:24px;'>What are you looking for today?</div>"
)
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(
"<div class='soft-note'><b>OR</b> fill in the form below</div>"
)
gr.HTML("<div class='block-title'>House Price Estimate</div>")
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(
"<footer>RMT52, LLC. 2026.</footer>"
)
if __name__ == "__main__":
demo.queue()
demo.launch(server_name="0.0.0.0")