import json import os import pickle from pathlib import Path import gradio as gr import numpy as np import pandas as pd import anthropic MODEL_PATH = Path("random_forest_regression.pkl") DATA_PATH = Path("bfs_municipality_and_tax_data.csv") # Hugging Face Secrets / local environment variables # Required secret on Hugging Face: ANTHROPIC_API_KEY LLM_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") LLM_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5") def load_model_and_data(): if not MODEL_PATH.exists(): raise FileNotFoundError( f"Missing model file: {MODEL_PATH}. Upload random_forest_regression.pkl to the same folder as app.py." ) if not DATA_PATH.exists(): raise FileNotFoundError( f"Missing data file: {DATA_PATH}. Upload bfs_municipality_and_tax_data.csv to the same folder as app.py." ) with open(MODEL_PATH, "rb") as model_file: loaded_model = pickle.load(model_file) bfs_data = pd.read_csv(DATA_PATH, sep=",", encoding="utf-8") bfs_data["tax_income"] = ( bfs_data["tax_income"].astype(str).str.replace("'", "", regex=False).astype(float) ) bfs_data["emp"] = bfs_data["emp"].fillna(bfs_data["emp"].median()) return loaded_model, bfs_data model, df_bfs_data = load_model_and_data() town_to_row = { str(row["bfs_name"]).strip().lower(): row for _, row in df_bfs_data.iterrows() } valid_towns = list(df_bfs_data["bfs_name"].sort_values().unique()) def match_town(user_town: str): """Match user town text to the canonical bfs_name in the CSV.""" if not user_town: return None query = str(user_town).strip().lower() if not query: return None # 1) Exact lower-case match if query in town_to_row: return town_to_row[query]["bfs_name"] # 2) Relaxed contains matching matches = [] for town in valid_towns: town_lower = str(town).strip().lower() if query == town_lower: return town if query in town_lower or town_lower in query: matches.append(town) if len(matches) == 1: return matches[0] return None def call_llm_json(system_prompt: str, user_prompt: str) -> str: """Call Anthropic Claude and require a JSON object response.""" if not LLM_API_KEY: raise RuntimeError( "ANTHROPIC_API_KEY fehlt. Bitte unter Hugging Face Settings -> Variables and secrets hinzufügen." ) client = anthropic.Anthropic(api_key=LLM_API_KEY) message = client.messages.create( model=LLM_MODEL, max_tokens=512, system=system_prompt, messages=[ {"role": "user", "content": user_prompt}, ], ) return message.content[0].text def parse_json_response(raw: str, required_keys: tuple) -> dict: cleaned = (raw or "").strip() # Remove markdown code fences if present if cleaned.startswith("```"): cleaned = cleaned.split("```")[1] if cleaned.startswith("json"): cleaned = cleaned[4:] cleaned = cleaned.strip() if not cleaned: raise ValueError("LLM hat eine leere Antwort zurückgegeben.") try: parsed = json.loads(cleaned) except json.JSONDecodeError as exc: raise ValueError( f"LLM hat kein gültiges JSON zurückgegeben. Erhalten: {cleaned[:300]}" ) from exc missing_keys = [key for key in required_keys if key not in parsed] if missing_keys: raise ValueError( f"JSON fehlt folgende Schlüssel: {', '.join(missing_keys)}." ) return parsed def extract_preferences(user_text: str) -> dict: """Extract rooms, area_m2 and town from German free text using an LLM.""" if not user_text or not user_text.strip(): raise ValueError( "Bitte gib einen Wohnungswunsch mit Zimmeranzahl, Fläche und Ort ein." ) system_prompt = """Du bist ein Extraktionssystem für Schweizer Wohnungswünsche. Extrahiere aus dem Nutzereingabetext exakt diese Informationen: - rooms: Anzahl Zimmer als Zahl, zum Beispiel 3.5 - area_m2: Wohnfläche in Quadratmetern als Zahl, zum Beispiel 85 - town: Schweizer Gemeinde / Ort als String, zum Beispiel "Winterthur" Antworte ausschliesslich mit gültigem JSON. Keine Markdown-Codeblöcke. Keine Erklärungen. Keine zusätzlichen Schlüssel. Wenn ein Wert fehlt, verwende null. Erwartetes Format: {"rooms": 3.5, "area_m2": 85, "town": "Winterthur"}""" user_prompt = f"""Extrahiere rooms, area_m2 und town aus folgendem deutschen Wohnungswunsch: {user_text}""" raw = call_llm_json(system_prompt, user_prompt) parsed = parse_json_response(raw, required_keys=("rooms", "area_m2", "town")) rooms = parsed["rooms"] area_m2 = parsed["area_m2"] town = parsed["town"] if rooms is None or area_m2 is None or town is None: raise ValueError("Bitte gib Zimmeranzahl, Wohnfläche in m2 und Ort an.") try: rooms = float(rooms) area_m2 = float(area_m2) except (TypeError, ValueError) as exc: raise ValueError( "Zimmeranzahl und Wohnfläche müssen als Zahlen erkannt werden." ) from exc if rooms <= 0: raise ValueError("Die Zimmeranzahl muss grösser als 0 sein.") if area_m2 <= 0: raise ValueError("Die Wohnfläche muss grösser als 0 sein.") canonical_town = match_town(town) if canonical_town is None: raise ValueError( f"Ort '{town}' wurde nicht eindeutig in den BFS-Daten gefunden. " "Bitte verwende einen gültigen Schweizer Gemeindenamen." ) return { "rooms": rooms, "area_m2": area_m2, "town": canonical_town, } def predict_apartment_price(rooms: float, area_m2: float, town: str) -> float: """Predict monthly rent with exactly these model features: [rooms, area_m2, pop, pop_dens, frg_pct, emp, tax_income] """ canonical_town = match_town(town) if canonical_town is None: raise ValueError(f"Ort '{town}' wurde nicht gefunden.") row = town_to_row[canonical_town.lower()] features = np.array( [[ float(rooms), float(area_m2), float(row["pop"]), float(row["pop_dens"]), float(row["frg_pct"]), float(row["emp"]), float(row["tax_income"]), ]] ) prediction = model.predict(features)[0] return round(float(prediction), 2) def generate_explanation(preferences: dict, prediction: float) -> str: """Generate a concise German explanation with one uncertainty note using an LLM.""" system_prompt = """Du bist ein hilfreicher Assistent für Wohnungsmietpreise in der Schweiz. Deine Aufgabe: Erkläre die gegebene Modellschätzung kurz und verständlich auf Deutsch. Wichtige Regeln: - Verwende die angegebene Prediction. - Berechne keinen neuen Preis. - Erfinde keine zusätzlichen Daten. - Erwähne Zimmeranzahl, Fläche und Ort. - Füge einen kurzen Unsicherheitshinweis hinzu. - Sage, dass es sich um eine Schätzung handelt. - Antworte ausschliesslich mit gültigem JSON ohne Markdown. - Verwende exakt den Schlüssel "answer". Erwartetes Format: {"answer": "Für eine 3.5-Zimmer-Wohnung mit 85 m2 in Winterthur schätzt das Modell die Monatsmiete auf rund 2145 CHF. Die Schätzung ist unsicher, weil Faktoren wie Zustand, Mikrolage und Ausstattung nicht direkt berücksichtigt werden."}""" user_prompt = f"""Wohnungsdaten: {json.dumps(preferences, ensure_ascii=False)} Modellschätzung der monatlichen Miete in CHF: {prediction} Formuliere eine kurze deutsche Antwort.""" raw = call_llm_json(system_prompt, user_prompt) parsed = parse_json_response(raw, required_keys=("answer",)) answer = parsed["answer"] if not answer: raise ValueError("LLM Erklärung war leer.") return str(answer) def run_pipeline(user_text: str): """End-to-end app pipeline.""" try: preferences = extract_preferences(user_text) prediction = predict_apartment_price( rooms=preferences["rooms"], area_m2=preferences["area_m2"], town=preferences["town"], ) final_answer = generate_explanation( preferences=preferences, prediction=prediction, ) return preferences, prediction, final_answer except Exception as exc: return ( { "error": str(exc), "hint": "Bitte gib Zimmeranzahl, Wohnfläche in m2 und einen Schweizer Ort an.", }, None, f"Fehler: {exc}", ) with gr.Blocks(title="🏠 Schweizer Wohnungspreisschätzer") as demo: gr.Markdown( """ # 🏠 Schweizer Wohnungspreisschätzer Beschreibe deinen **Wohnungswunsch auf Deutsch**. **Beispiele:** - *„Ich suche eine 3.5-Zimmer-Wohnung mit etwa 85 m2 in Winterthur."* - *„Wie viel kostet eine 2-Zimmer-Wohnung mit 55 m2 in Zürich?"* - *„Ich brauche eine 4.5-Zimmer-Wohnung mit 110 m2 in Bern."* """ ) user_text = gr.Textbox( label="Wohnungswunsch", lines=3, placeholder="Beschreibe Zimmer, Fläche in m2 und Ort auf Deutsch...", ) submit = gr.Button("🔍 Schätzen", variant="primary") with gr.Row(): extracted = gr.JSON(label="📋 Extrahierte Eingaben") price = gr.Number(label="💰 Geschätzte Monatsmiete (CHF)") response = gr.Textbox(label="💬 Erklärung", lines=5) submit.click( fn=run_pipeline, inputs=[user_text], outputs=[extracted, price, response], ) gr.Markdown( """ --- *Hinweis: Diese Schätzung basiert auf einem Random-Forest-Modell mit BFS-Gemeindedaten. Sie dient nur zur Orientierung und ersetzt keine professionelle Mietberatung.* """ ) if __name__ == "__main__": demo.launch()