Skip to content
0

🌐 Prototype web (work in progress)

For this competition, the reference implementation of the Dog Matchmaker is this notebook: it contains the full data pipeline, scoring logic, and explanations.

I am also experimenting with a small web prototype built with Lovable: https://dog-match-mate.lovable.app

⚠️ This web app is not the final or official version of the project: it is a work in progress and may not reflect all the logic implemented in this notebook (e.g. some questions, filters, and scoring details are still being integrated).

Last update of the web prototype:
21/11/2025 — 22:25 (GMT+1)

🐕 Dog Matchmaker – From Robot Dog to Real-World Breeds

A robotics company is launching a robot dog and wants its personality to feel as natural as a real pet.
This notebook transforms their dataset into an AI Dog Matchmaker that recommends the top 3 dog breeds for any lifestyle.

We integrate:

  • Structured breed traits (affection, kid-friendliness, energy, barking, shedding, trainability, etc.)
  • A conversational flow that turns user answers into a structured profile
  • A hybrid matching engine (hard constraints + weighted scoring on 1–5 behavioral scales)
  • Optional semantic embeddings to interpret the user’s free-text description
  • Breed images and a social-media-ready caption for the top match

At the end of this notebook, you can:

  • Run an automatic demo that illustrates the full recommendation pipeline
  • Interact with the Dog Matchmaker chatbot directly inside the notebook

Objective: help the product team design a robot dog persona grounded in real-world canine behavior and user expectations.

🔍 Methodology – How the system matches people with dog breeds

The goal of this project is not only to process a dataset, but to design a simple, interpretable recommendation engine that mimics how a real expert would advise someone choosing a dog.

1. Data used

We rely on:

  • A structured table of breed traits (energy, barking, shedding, trainability, child-friendliness…)
  • A companion table with human-readable trait descriptions
  • A large repository of breed images for visual results

These datasets let us translate lifestyle questions into measurable criteria.


2. Converting user answers into a profile

The chatbot turns the conversation into a clear user profile:

  • Family context (kids, other dogs, allergies)
  • Preferences (energy, noise, grooming, shedding)
  • Behavioral expectations (trainability, friendliness, adaptability)
  • Optional free-text description of their “ideal dog vibe”

This profile becomes the input of the recommender.


3. Matching logic

The engine combines two layers:

Hard constraints
(e.g., allergies → remove heavy-shedding breeds)

Soft compatibility scoring
A weighted comparison between user preferences and breed traits:

  • Energy match
  • Barking tolerance
  • Trainability importance
  • Compatibility with children / other dogs
  • Grooming and shedding constraints
  • Stranger friendliness
  • Adaptability

Everything is normalized to produce an interpretable match score between 0 and 1.

An optional semantic layer (sentence-transformers) captures signals from the user’s free-text description, adding a small “personal taste” boost.


4. Results & user experience

For each profile, the system:

  • Ranks all breeds by match score
  • Returns the top 3 recommendations
  • Displays images, interpretable explanations, and a social-media-ready caption
  • Allows either a one-shot demo or a fully interactive chat session inside the notebook

This creates a transparent AI assistant that connects human preferences with real-world breed characteristics.

# ============================================================
# 1. Imports and data loading
# ============================================================
import os
import re
import textwrap

import numpy as np
import pandas as pd
from dataclasses import dataclass, asdict
from IPython.display import display, Image, Markdown


os.environ["TOKENIZERS_PARALLELISM"] = "false"
# ------------------------------------------------------------------
# Expected files:
#   - data/breed_traits.csv
#   - data/trait_description.csv
# ------------------------------------------------------------------

breed_traits_path = "data/breed_traits.csv"
trait_desc_path = "data/trait_description.csv"

dog_breeds = pd.read_csv(breed_traits_path)
trait_descriptions = pd.read_csv(trait_desc_path)

dog_breeds["_row_id"] = np.arange(len(dog_breeds))

# Quick sanity check
print("Breed traits shape:", dog_breeds.shape)
print("Trait description shape:", trait_descriptions.shape)
print("Sample breeds:", dog_breeds["Breed"].head().tolist())

# ============================================================
# 2. Data preprocessing and helpers
# ============================================================

# Columns that are numeric scales 1–5 stored as text
NUMERIC_TRAIT_COLS = [
    "Affectionate With Family",
    "Good With Young Children",
    "Good With Other Dogs",
    "Shedding Level",
    "Coat Grooming Frequency",
    "Drooling Level",
    "Openness To Strangers",
    "Playfulness Level",
    "Watchdog/Protective Nature",
    "Adaptability Level",
    "Trainability Level",
    "Energy Level",
    "Barking Level",
    "Mental Stimulation Needs",
]

# Convert numeric traits to integers
for col in NUMERIC_TRAIT_COLS:
    dog_breeds[col] = pd.to_numeric(dog_breeds[col], errors="coerce").astype("Int64")

# Build a dictionary for trait descriptions
trait_desc_map = {}
for _, row in trait_descriptions.iterrows():
    name = row["Trait"]
    trait_desc_map[name] = {
        "low_label": row["Trait_1"],
        "high_label": row["Trait_5"],
        "long_desc": row["Description"],
    }

def describe_trait_level(trait_name: str, level: int | float | None) -> str:
    """
    Turn a numeric level (1–5) into a human-readable short description
    using trait_descriptions when available.
    """
    if level is None or pd.isna(level):
        return f"{trait_name}: unknown"

    level = int(level)
    base = trait_desc_map.get(trait_name, None)
    if base is None:
        return f"{trait_name}: {level}/5"

    low = base["low_label"]
    high = base["high_label"]
    if level <= 2:
        qual = low
    elif level >= 4:
        qual = high
    else:
        qual = f"Moderate {trait_name.lower()}"
    return f"{trait_name}: {level}/5 ({qual})"

# ============================================================
# 3. User profile definition
# ============================================================

@dataclass
class UserProfile:
    # lifestyle and family
    has_kids: bool | None = None
    has_other_dogs: bool | None = None
    allergies: bool | None = None         # to hair/dander
    wants_guard_dog: bool | None = None
    # numeric preferences on 1–5 scale
    desired_energy: int | None = None       # maps to Energy Level
    noise_tolerance: int | None = None      # maps to Barking Level tolerance
    grooming_tolerance: int | None = None   # inverse of Coat Grooming Frequency
    shedding_tolerance: int | None = None    # maps to Shedding Level tolerance
    stranger_friendly_pref: int | None = None # Openness To Strangers
    trainability_importance: int | None = None # Trainability Level importance
    mental_stimulation_importance: int | None = None # Mental Stimulation Needs alignment
    adaptability_importance: int | None = None       # Adaptability Level
    # free text (not used by the scoring engine, but can be used later)
    free_text_intro: str | None = None


# ============================================================
# 4. Simple NLU helpers (parsing yes/no and scales)
# ============================================================

def parse_yes_no(text: str) -> bool | None:
    t = text.strip().lower()
    yes_words = ["yes", "y", "yeah", "yep", "oui", "of course", "sure"]
    no_words = ["no", "n", "nope", "non"]
    if any(w in t for w in yes_words):
        return True
    if any(w in t for w in no_words):
        return False
    return None


def parse_scale_1_5(text: str, default: int = 3) -> int:
    """
    Parse a 1–5 answer. If parsing fails, return default.
    """
    t = text.strip().lower()
    # Try to extract an integer
    m = re.search(r"[1-5]", t)
    if m:
        val = int(m.group(0))
        return max(1, min(5, val))

    # Try some qualitative mappings
    if any(w in t for w in ["very low", "calm", "quiet", "minimal"]):
        return 1
    if any(w in t for w in ["low", "rather low", "not much"]):
        return 2
    if any(w in t for w in ["medium", "moderate", "balanced"]):
        return 3
    if any(w in t for w in ["high", "a lot", "active"]):
        return 4
    if any(w in t for w in ["very high", "hyper", "intense"]):
        return 5

    return default


# ============================================================
# 5. Matching engine (hard filters + soft scoring)
# ============================================================

def hard_filter_breeds(df: pd.DataFrame, user: UserProfile) -> pd.DataFrame:
    """
    Apply strong constraints where it makes sense, e.g. allergies.
    Returns a filtered copy.
    """
    filtered = df.copy()

    # Allergies: reduce to low shedding if user has allergies
    if user.allergies is True:
        # 1 = No shedding, 5 = Hair everywhere
        filtered = filtered[filtered["Shedding Level"] <= 2]

    # If user definitely does NOT want a guard/protective dog
    if user.wants_guard_dog is False:
        # Keep breeds with moderate or lower protective nature
        filtered = filtered[filtered["Watchdog/Protective Nature"] <= 3]

    return filtered


def score_breed_row(row: pd.Series, user: UserProfile) -> float:
    """
    Compute a weighted similarity score between a breed and the user's preferences.
    All component scores are in [0, 1]; final score is a weighted average in [0, 1].
    """
    scores = []
    weights = []

    # Energy level (match desired energy)
    if user.desired_energy is not None and not pd.isna(row["Energy Level"]):
        diff = abs(int(row["Energy Level"]) - int(user.desired_energy))
        s_energy = 1 - diff / 4    # max diff = |1-5| = 4
        scores.append(s_energy)
        weights.append(2.0)        # energy is important

    # Barking level vs noise tolerance:
    # if breed barking > tolerance, penalty; else ok.
    if user.noise_tolerance is not None and not pd.isna(row["Barking Level"]):
        barking = int(row["Barking Level"])
        tolerance = int(user.noise_tolerance)
        penalty = max(0, barking - tolerance)
        s_noise = 1 - penalty / 4
        scores.append(s_noise)
        weights.append(1.5)

    # Shedding level vs shedding tolerance
    if user.shedding_tolerance is not None and not pd.isna(row["Shedding Level"]):
        shedding = int(row["Shedding Level"])
        tol = int(user.shedding_tolerance)
        diff = max(0, shedding - tol)
        s_shed = 1 - diff / 4
        scores.append(s_shed)
        # If allergies, make this more important
        w_shed = 2.0 if user.allergies else 1.5
        weights.append(w_shed)

    # Grooming vs grooming tolerance
    if user.grooming_tolerance is not None and not pd.isna(row["Coat Grooming Frequency"]):
        groom_need = int(row["Coat Grooming Frequency"])
        tol = int(user.grooming_tolerance)
        diff = max(0, groom_need - tol)
        s_groom = 1 - diff / 4
        scores.append(s_groom)
        weights.append(1.0)

    # Kids: if user has kids, push "Good With Young Children" high
    if user.has_kids is True and not pd.isna(row["Good With Young Children"]):
        kids = int(row["Good With Young Children"])
        s_kids = kids / 5
        scores.append(s_kids)
        weights.append(2.0)

    # Other dogs at home: use "Good With Other Dogs"
    if user.has_other_dogs is True and not pd.isna(row["Good With Other Dogs"]):
        other = int(row["Good With Other Dogs"])
        s_other = other / 5
        scores.append(s_other)
        weights.append(1.5)

    # Trainability importance
    if user.trainability_importance is not None and not pd.isna(row["Trainability Level"]):
        train = int(row["Trainability Level"])
        importance = int(user.trainability_importance) / 5
        s_train = train / 5
        scores.append(s_train)
        weights.append(1.5 * importance + 0.5)

    # Mental stimulation / playfulness for users who want to work with the dog
    if user.mental_stimulation_importance is not None and not pd.isna(row["Mental Stimulation Needs"]):
        mental = int(row["Mental Stimulation Needs"])
        importance = int(user.mental_stimulation_importance) / 5
        s_mental = mental / 5
        scores.append(s_mental)
        weights.append(1.2 * importance + 0.3)

    # Stranger friendliness preference
    if user.stranger_friendly_pref is not None and not pd.isna(row["Openness To Strangers"]):
        desired_open = int(user.stranger_friendly_pref)
        diff = abs(int(row["Openness To Strangers"]) - desired_open)
        s_stranger = 1 - diff / 4
        scores.append(s_stranger)
        weights.append(1.0)

    # Adaptability importance
    if user.adaptability_importance is not None and not pd.isna(row["Adaptability Level"]):
        adapt = int(row["Adaptability Level"])
        importance = int(user.adaptability_importance) / 5
        s_adapt = adapt / 5
        scores.append(s_adapt)
        weights.append(1.0 * importance + 0.5)

    if not scores or sum(weights) == 0:
        return 0.0

    scores = np.array(scores, dtype=float)
    weights = np.array(weights, dtype=float)
    return float(np.clip((scores * weights).sum() / weights.sum(), 0.0, 1.0))


def rank_breeds(dog_breeds: pd.DataFrame, user: UserProfile, top_k: int = 3) -> pd.DataFrame:
    """
    Apply hard filters, compute scores, and return top_k breeds with:
      - match_score_structured : score basé sur les traits numériques
      - match_score_semantic   : (optionnel) score basé sur embeddings texte
      - match_score            : combinaison des deux
    """
    filtered = hard_filter_breeds(dog_breeds, user)
    if filtered.empty:
        return filtered

    # 1) score basé sur les traits structurés
    struct_scores = []
    for _, row in filtered.iterrows():
        struct_scores.append(score_breed_row(row, user))

    filtered = filtered.copy()
    filtered["match_score_structured"] = struct_scores

    # 2) optionnel : score sémantique à partir du texte libre de l'utilisateur
    use_semantic = (
        EMBEDDING_AVAILABLE
        and EMBEDDING_MODEL is not None
        and breed_embs is not None
        and isinstance(user.free_text_intro, str)
        and user.free_text_intro.strip() != ""
    )

    if use_semantic:
        try:
            # encode user text
            u_emb = EMBEDDING_MODEL.encode([user.free_text_intro], normalize_embeddings=True)[0]

            # on doit aligner chaque ligne filtrée avec son embedding global
            row_ids = filtered["_row_id"].astype(int).values
            # breed_embs[row_ids] : matrice (n_filtered, dim)
            sims = breed_embs[row_ids] @ u_emb  # produit scalaire cosinus (car normalisé)
            # Rescale from [-1,1] to [0,1] just in case
            sims = (sims + 1.0) / 2.0

            filtered["match_score_semantic"] = sims

            # Combinaison : 70% traits structurés, 30% similarité sémantique
            alpha = 0.7
            filtered["match_score"] = alpha * filtered["match_score_structured"] + (1 - alpha) * filtered["match_score_semantic"]
        except Exception as e:
            print("Semantic scoring failed, falling back to structured only:", e)
            filtered["match_score_semantic"] = np.nan
            filtered["match_score"] = filtered["match_score_structured"]
    else:
        # pas d'embedding dispo ou pas de texte → uniquement traits
        filtered["match_score_semantic"] = np.nan
        filtered["match_score"] = filtered["match_score_structured"]

    # Tri final
    filtered = filtered.sort_values("match_score", ascending=False)
    return filtered.head(top_k)


# ============================================================
# 6. Dog image retrieval from GitHub dataset
# ============================================================



IMAGE_ROOT = "data/dog_images/Dog-Breeds-Dataset"
if not os.path.isdir(IMAGE_ROOT):
    # clone only if folder does not exist
    !git clone https://github.com/maartenvandenbroeck/Dog-Breeds-Dataset.git $IMAGE_ROOT
    
if os.path.isdir(IMAGE_ROOT):
    image_folders = [f for f in os.listdir(IMAGE_ROOT) if os.path.isdir(os.path.join(IMAGE_ROOT, f))]
    print(f"Found {len(image_folders)} image folders under {IMAGE_ROOT}.")
else:
    image_folders = []
    print(f"No image root folder '{IMAGE_ROOT}' found. Images will be skipped.")


def find_best_image_folder(breed_name: str) -> str | None:
    """
    Fuzzy match breed_name to a folder in the cloned Dog-Breeds-Dataset.
    Strategy: compare word overlap between AKC breed name and folder names.
    """
    if not image_folders:
        return None

    breed_words = set(re.sub(r"[^a-z ]", " ", breed_name.lower()).split())
    best_folder = None
    best_score = 0

    for folder in image_folders:
        folder_words = set(re.sub(r"[^a-z ]", " ", folder.lower()).split())
        # Intersection size as a simple similarity score
        score = len(breed_words & folder_words)
        if score > best_score:
            best_score = score
            best_folder = folder

    if best_score == 0:
        return None
    return best_folder


def get_example_images_for_breed(breed_name: str, max_images: int = 3) -> list[str]:
    """
    Return a list of local image file paths (relative to notebook) for the given breed,
    using the cloned GitHub repository. If no images found, return [].
    """
    folder = find_best_image_folder(breed_name)
    if folder is None:
        return []

    folder_path = os.path.join(IMAGE_ROOT, folder)
    if not os.path.isdir(folder_path):
        return []

    # Take the first few JPEGs
    files = [f for f in os.listdir(folder_path) if f.lower().endswith((".jpg", ".jpeg", ".png"))]
    files = sorted(files)[:max_images]
    return [os.path.join(folder_path, f) for f in files]


# ============================================================
# 7. Presentation helpers (executive summary, result cards)
# ============================================================

def summarize_breed(breed_row: pd.Series) -> str:
    """
    Use trait_descriptions and key traits to build a short explanation for the breed.
    """
    lines = []

    lines.append(f"Breed: {breed_row['Breed']}")
    # Family and kids
    lines.append(
        describe_trait_level("Affectionate With Family", breed_row["Affectionate With Family"])
    )
    lines.append(
        describe_trait_level("Good With Young Children", breed_row["Good With Young Children"])
    )
    # Energy and mental needs
    lines.append(describe_trait_level("Energy Level", breed_row["Energy Level"]))
    lines.append(
        describe_trait_level("Mental Stimulation Needs", breed_row["Mental Stimulation Needs"])
    )
    # Shedding / grooming / barking
    lines.append(describe_trait_level("Shedding Level", breed_row["Shedding Level"]))
    lines.append(
        describe_trait_level("Coat Grooming Frequency", breed_row["Coat Grooming Frequency"])
    )
    lines.append(describe_trait_level("Barking Level", breed_row["Barking Level"]))

    text = "\n".join(lines)
    return textwrap.dedent(text)


def generate_social_media_caption(breed_row: pd.Series, user: UserProfile) -> str:
    """
    Simple text-only post-style description for the top match.
    """
    traits = []
    traits.append(f"Energy level {breed_row['Energy Level']}/5")
    traits.append(f"Affection with family {breed_row['Affectionate With Family']}/5")
    traits.append(f"Good with children {breed_row['Good With Young Children']}/5")
    traits_str = ", ".join(traits)

    profile_bits = []
    if user.has_kids:
        profile_bits.append("family with children")
    if user.allergies:
        profile_bits.append("sensitive to shedding")
    if user.desired_energy is not None:
        profile_bits.append(f"likes activity level {user.desired_energy}/5")

    profile_str = ", ".join(profile_bits) if profile_bits else "my lifestyle"

    caption = f"""\
My ideal dog match: {breed_row['Breed']}.

They fit {profile_str} with traits like: {traits_str}.

If you are looking for a dog with similar energy and temperament, this breed is worth considering."""
    return textwrap.dedent(caption)


def display_results(top_breeds: pd.DataFrame, user: UserProfile) -> None:
    """
    Nicely render the top 3 matches with images (if available).
    """
    if top_breeds.empty:
        display(Markdown("### No suitable breeds found\n\nYour constraints are very strict. You may want to relax shedding or barking constraints and try again."))
        return

    display(Markdown("## Your top dog breed matches"))

    for rank, (_, row) in enumerate(top_breeds.iterrows(), start=1):
        score = row["match_score"]
        display(Markdown(f"### #{rank}: {row['Breed']} (match score: {score:.2f})"))

        # Show images if available
        image_paths = get_example_images_for_breed(row["Breed"], max_images=3)
        if image_paths:
            for img_path in image_paths:
                try:
                    display(Image(filename=img_path, width=250))
                except Exception as e:
                    print(f"Could not display image {img_path}: {e}")
        else:
            display(Markdown("_No local images found for this breed (check Dog-Breeds-Dataset clone)._"))

        # Text summary
        summary = summarize_breed(row)
        display(Markdown("**Why this is a good match for you**"))
        display(Markdown(f"```text\n{summary}\n```"))

    # Social media style caption for the top match
    best_row = top_breeds.iloc[0]
    caption = generate_social_media_caption(best_row, user)
    display(Markdown("## Social-media-ready caption for your top match"))
    display(Markdown(f"```text\n{caption}\n```"))


# ============================================================
# 8. Chatbot flow (interactive CLI in the notebook)
# ============================================================

def run_dog_matchmaker_chat():
    """
    Simple console chatbot that collects user preferences
    and prints top 3 breed recommendations.
    You run it in a notebook cell and answer in the console.
    """
    print("Welcome to the Dog Matchmaker.")
    print("I will ask you a few questions about your lifestyle and preferences.")
    print("Then I will recommend the top 3 dog breeds that best fit you.")
    print("-" * 60)

    user = UserProfile()

    # Free-text intro (not used by the scoring engine, but could be logged)
    intro = input("First, in a few words, describe your ideal life with a dog:\n> ")
    user.free_text_intro = intro

    # Family and home context
    while user.has_kids is None:
        ans = input("Do you have young children at home? (yes/no)\n> ")
        user.has_kids = parse_yes_no(ans)
        if user.has_kids is None:
            print("I did not understand. Please answer with yes or no.")

    while user.has_other_dogs is None:
        ans = input("Do you already have other dogs at home? (yes/no)\n> ")
        user.has_other_dogs = parse_yes_no(ans)
        if user.has_other_dogs is None:
            print("I did not understand. Please answer with yes or no.")

    while user.allergies is None:
        ans = input("Does anyone in your home have dog-related allergies (hair/dander)? (yes/no)\n> ")
        user.allergies = parse_yes_no(ans)
        if user.allergies is None:
            print("I did not understand. Please answer with yes or no.")

    while user.wants_guard_dog is None:
        ans = input("Are you specifically looking for a watchdog/protective dog? (yes/no)\n> ")
        user.wants_guard_dog = parse_yes_no(ans)
        if user.wants_guard_dog is None:
            print("I did not understand. Please answer with yes or no.")

    # Preferences on numeric scales
    de = input("On a scale from 1 (very calm) to 5 (very energetic), what energy level do you prefer?\n> ")
    user.desired_energy = parse_scale_1_5(de, default=3)

    nt = input("On a scale from 1 (needs to be very quiet) to 5 (noise is fine), how much barking can you tolerate?\n> ")
    user.noise_tolerance = parse_scale_1_5(nt, default=3)

    gt = input("On a scale from 1 (almost no grooming) to 5 (regular grooming is fine), how much grooming can you handle?\n> ")
    user.grooming_tolerance = parse_scale_1_5(gt, default=3)

    st = input("On a scale from 1 (very low shedding) to 5 (shedding is not a problem), how much shedding can you tolerate?\n> ")
    user.shedding_tolerance = parse_scale_1_5(st, default=3)

    sf = input("On a scale from 1 (prefers reserved with strangers) to 5 (very friendly with strangers), what do you prefer?\n> ")
    user.stranger_friendly_pref = parse_scale_1_5(sf, default=3)

    tr = input("On a scale from 1 (trainability does not matter) to 5 (very important that the dog is easy to train), how important is trainability for you?\n> ")
    user.trainability_importance = parse_scale_1_5(tr, default=4)

    ms = input("On a scale from 1 (no need for games/mental work) to 5 (I want to do many activities and games), how important is mental stimulation with your dog?\n> ")
    user.mental_stimulation_importance = parse_scale_1_5(ms, default=3)

    ad = input("On a scale from 1 (adaptability not important) to 5 (very important that the dog adapts to changes, travel, etc.), how important is adaptability?\n> ")
    user.adaptability_importance = parse_scale_1_5(ad, default=3)

    print("\nThank you. Here is the profile I built from your answers:")
    for k, v in asdict(user).items():
        print(f"  - {k}: {v}")

    print("\nComputing your best dog breed matches...")
    top = rank_breeds(dog_breeds, user, top_k=3)
    display_results(top, user)
    print("\nEnd of Dog Matchmaker session.")
# ============================================================
# Optional: semantic embeddings for free-text intro
# ============================================================
try:
    from sentence_transformers import SentenceTransformer
    EMBEDDING_AVAILABLE = True
except Exception as e:
    print("Warning: sentence_transformers not available, semantic matching disabled:", e)
    SentenceTransformer = None
    EMBEDDING_AVAILABLE = False

EMBEDDING_MODEL = None
breed_embs = None  # will store embeddings aligned with dog_breeds["_row_id"]

def build_breed_text(row: pd.Series) -> str:
    """
    Build a textual description per breed that we will embed.
    The goal is not to be perfect, but to capture overall 'vibe':
    family, kids, energy, barking, shedding, trainability, etc.
    """
    parts = [str(row["Breed"])]

    def safe_int(x):
        try:
            return int(x)
        except Exception:
            return None

    aff = safe_int(row.get("Affectionate With Family", None))
    kids = safe_int(row.get("Good With Young Children", None))
    energy = safe_int(row.get("Energy Level", None))
    bark = safe_int(row.get("Barking Level", None))
    shed = safe_int(row.get("Shedding Level", None))
    train = safe_int(row.get("Trainability Level", None))
    mental = safe_int(row.get("Mental Stimulation Needs", None))

    if aff is not None:
        parts.append(f"affection with family {aff}/5")
    if kids is not None:
        parts.append(f"good with children {kids}/5")
    if energy is not None:
        parts.append(f"energy level {energy}/5")
    if bark is not None:
        parts.append(f"barking level {bark}/5")
    if shed is not None:
        parts.append(f"shedding level {shed}/5")
    if train is not None:
        parts.append(f"trainability {train}/5")
    if mental is not None:
        parts.append(f"mental stimulation needs {mental}/5")

    return ". ".join(parts)


if EMBEDDING_AVAILABLE:
    try:
        print("Loading sentence-transformers model for semantic matching...")
        EMBEDDING_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
        breed_texts = dog_breeds.apply(build_breed_text, axis=1).tolist()
        breed_embs = EMBEDDING_MODEL.encode(breed_texts, normalize_embeddings=True)
        print("Semantic embeddings ready for", len(breed_embs), "breeds.")
    except Exception as e:
        print("Could not load embedding model or compute embeddings, disabling semantic matching:", e)
        EMBEDDING_MODEL = None
        breed_embs = None
        EMBEDDING_AVAILABLE = False
else:
    print("Semantic matching is disabled (no sentence_transformers).")
# ============================================================
# 10. Automatic demo (non-interactive)
# ============================================================

demo_profile = UserProfile(
    has_kids=True,
    has_other_dogs=False,
    allergies=False,
    wants_guard_dog=False,
    desired_energy=3,
    noise_tolerance=3,
    grooming_tolerance=3,
    shedding_tolerance=3,
    stranger_friendly_pref=4,
    trainability_importance=4,
    mental_stimulation_importance=3,
    adaptability_importance=4,
    free_text_intro="I want a playful family dog that fits apartment life."
)

demo_top = rank_breeds(dog_breeds, demo_profile, top_k=3)
display_results(demo_top, demo_profile)
# ============================================================
# 11. Optional interactive chatbot session
# ============================================================

# If you want to talk to the Dog Matchmaker in the notebook,
# uncomment the line below and run this cell manually.

# run_dog_matchmaker_chat()