from csv import DictReader import math import os from pathlib import Path from statistics import median from flask import Flask, jsonify, request try: import pymysql except ImportError: pymysql = None BASE_DIR = Path(__file__).resolve().parents[1] LARAVEL_ENV_PATH = BASE_DIR / ".env" def dataset_candidates(): env_path = os.getenv("CAR_DATASET_PATH") paths = [] if env_path: paths.append(Path(env_path)) paths.extend( [ BASE_DIR / "storage" / "app" / "dataset_final.csv", BASE_DIR / "database" / "dataset_final.csv", BASE_DIR / "database" / "dataset_final.xls", BASE_DIR / "storage" / "app" / "dataset_mobil.csv", ] ) return paths app = Flask(__name__) def parse_env_file(path): values = {} if not path.exists(): return values with path.open("r", encoding="utf-8") as env_file: for line in env_file: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) values[key.strip()] = value.strip().strip('"').strip("'") return values def env_value(key, default=None): return os.getenv(key) or parse_env_file(LARAVEL_ENV_PATH).get(key, default) def database_config(): return { "host": env_value("DB_HOST", "127.0.0.1"), "port": to_int(env_value("DB_PORT", 3306), default=3306), "user": env_value("DB_USERNAME", "root"), "password": env_value("DB_PASSWORD", ""), "database": env_value("DB_DATABASE", ""), "charset": "utf8mb4", "cursorclass": pymysql.cursors.DictCursor if pymysql else None, } def to_int(value, default=0): try: return int(float(value)) except (TypeError, ValueError): return default def normalize_text(value): return str(value or "").strip().lower() def as_list(value): if value in (None, ""): return [] if isinstance(value, (list, tuple, set)): return [normalize_text(item) for item in value if normalize_text(item)] return [normalize_text(value)] def clamp(value, minimum=0.0, maximum=1.0): return max(minimum, min(maximum, value)) def closeness_score(value, target, tolerance): if target in (None, ""): return 0.5 target = to_int(target, default=None) if target in (None, 0): return 0.5 return clamp(1 - abs(value - target) / max(tolerance, 1)) def parse_request_options(payload): preferences = payload.get("preferences") or payload.get("preferensi") or {} filters = payload.get("filters") or payload.get("filter") or {} # Backward compatibility with the first simple API. if payload.get("cluster") is not None and preferences.get("cluster") is None: preferences["cluster"] = payload.get("cluster") return preferences, filters def category_row_to_car(row, index): merk = str(row.get("merk") or "").strip() model = str(row.get("model") or "").strip() name = str(row.get("name") or "").strip() composed_name = f"{merk} {model}".strip() or name return { "id": row.get("id") or index, "merk": merk, "model": model, "nama": composed_name, "kategori": str(row.get("kategori") or "").strip(), "tahun": to_int(row.get("year")), "mesin_cc": to_int(row.get("mesin_cc")), "transmisi": str(row.get("transmisi") or "").strip(), "bahan_bakar": str(row.get("bahan_bakar") or "").strip(), "harga": to_int(row.get("price")), "cluster": None, "source": "database", } def load_cars_from_database(): if pymysql is None: raise RuntimeError("PyMySQL belum terpasang. Jalankan pip install -r flask_api/requirements.txt") config = database_config() if not config["database"]: raise RuntimeError("DB_DATABASE belum diatur.") connection = pymysql.connect(**config) try: with connection.cursor() as cursor: cursor.execute( """ SELECT id, name, merk, model, kategori, price, year, mesin_cc, transmisi, bahan_bakar FROM categories WHERE status = 'available' AND price > 0 AND year IS NOT NULL AND mesin_cc IS NOT NULL ORDER BY created_at DESC, id DESC """ ) rows = cursor.fetchall() finally: connection.close() cars = [] for index, row in enumerate(rows, start=1): car = category_row_to_car(row, index) if car["harga"] > 0: cars.append(car) return cars def load_cars_from_dataset(): dataset_path = get_dataset_path() if not dataset_path: searched_paths = [str(path) for path in dataset_candidates()] raise FileNotFoundError(f"Dataset tidak ditemukan. Lokasi yang dicek: {searched_paths}") cars = [] with dataset_path.open("r", encoding="utf-8-sig", newline="") as dataset: for index, row in enumerate(DictReader(dataset), start=1): price = to_int(row.get("Harga")) if price <= 0: continue cars.append( { "id": index, "merk": row.get("merk", "").strip(), "model": row.get("model", "").strip(), "nama": f"{row.get('merk', '').strip()} {row.get('model', '').strip()}".strip(), "kategori": row.get("kategori", "").strip(), "tahun": to_int(row.get("tahun")), "mesin_cc": to_int(row.get("mesin_cc")), "transmisi": row.get("transmisi", "").strip(), "bahan_bakar": row.get("bahan_bakar", "").strip(), "harga": price, "cluster": to_int(row.get("cluster"), default=None), "source": "dataset", } ) return cars def load_cars(): try: cars = load_cars_from_database() if cars: return cars except Exception: pass return load_cars_from_dataset() def get_dataset_path(): for path in dataset_candidates(): if path.exists() and path.is_file(): return path return None def matches_filters(car, filters): allowed_categories = as_list(filters.get("kategori") or filters.get("category")) allowed_transmissions = as_list(filters.get("transmisi") or filters.get("transmission")) allowed_fuels = as_list(filters.get("bahan_bakar") or filters.get("fuel")) allowed_brands = as_list(filters.get("merk") or filters.get("brand")) allowed_clusters = [to_int(cluster, default=None) for cluster in as_list(filters.get("cluster"))] allowed_clusters = [cluster for cluster in allowed_clusters if cluster is not None] if allowed_categories and normalize_text(car["kategori"]) not in allowed_categories: return False if allowed_transmissions and normalize_text(car["transmisi"]) not in allowed_transmissions: return False if allowed_fuels and normalize_text(car["bahan_bakar"]) not in allowed_fuels: return False if allowed_brands and normalize_text(car["merk"]) not in allowed_brands: return False if allowed_clusters and car["cluster"] not in allowed_clusters: return False min_year = to_int(filters.get("tahun_min") or filters.get("min_tahun"), default=None) max_year = to_int(filters.get("tahun_max") or filters.get("max_tahun"), default=None) min_engine = to_int(filters.get("mesin_min") or filters.get("min_mesin_cc"), default=None) max_engine = to_int(filters.get("mesin_max") or filters.get("max_mesin_cc"), default=None) if min_year is not None and car["tahun"] < min_year: return False if max_year is not None and car["tahun"] > max_year: return False if min_engine is not None and car["mesin_cc"] < min_engine: return False if max_engine is not None and car["mesin_cc"] > max_engine: return False return True def preference_match_score(car, preferences): scores = [] reasons = [] preference_fields = [ ("merk", "merk"), ("kategori", "kategori"), ("transmisi", "transmisi"), ("bahan_bakar", "bahan bakar"), ] for field, label in preference_fields: desired_values = as_list(preferences.get(field)) if not desired_values: continue if normalize_text(car[field]) in desired_values: scores.append(1) reasons.append(f"Cocok dengan preferensi {label}: {car[field]}") else: scores.append(0) preferred_cluster = preferences.get("cluster") if preferred_cluster is not None: preferred_cluster = to_int(preferred_cluster, default=None) scores.append(1 if car["cluster"] == preferred_cluster else 0) if car["cluster"] == preferred_cluster: reasons.append(f"Sesuai cluster preferensi: {car['cluster']}") return (sum(scores) / len(scores) if scores else 0.5), reasons def score_car(car, budget, preferences, stats, strategy, weights): budget_ratio = car["harga"] / max(budget, 1) budget_gap = budget - car["harga"] if strategy == "hemat": target_ratio = 0.80 elif strategy == "premium": target_ratio = 0.97 elif strategy in ["harga_pas", "harga pas", "spesifik", "specific"]: target_ratio = 1.00 else: target_ratio = 0.90 budget_score = clamp(1 - abs(budget_ratio - target_ratio) / 0.35) year_score = clamp((car["tahun"] - stats["min_year"]) / max(stats["max_year"] - stats["min_year"], 1)) engine_score = closeness_score( car["mesin_cc"], preferences.get("mesin_cc") or preferences.get("target_mesin_cc"), tolerance=700, ) preference_score, preference_reasons = preference_match_score(car, preferences) preferred_cluster = preferences.get("cluster") cluster_score = 0.5 if preferred_cluster is not None: cluster_score = 1 if car["cluster"] == to_int(preferred_cluster, default=None) else 0 elif stats["median_cluster"] is not None: cluster_score = 1 / (1 + abs(car["cluster"] - stats["median_cluster"])) weighted_score = ( budget_score * weights["budget"] + preference_score * weights["preference"] + year_score * weights["year"] + engine_score * weights["engine"] + cluster_score * weights["cluster"] ) reasons = [] if budget_gap >= 0: reasons.append(f"Masih di bawah budget dengan sisa Rp{budget_gap:,.0f}") else: reasons.append(f"Melebihi budget Rp{abs(budget_gap):,.0f}") if year_score >= 0.75: reasons.append(f"Tahun relatif muda: {car['tahun']}") if engine_score >= 0.8 and (preferences.get("mesin_cc") or preferences.get("target_mesin_cc")): reasons.append(f"Kapasitas mesin dekat preferensi: {car['mesin_cc']} cc") reasons.extend(preference_reasons) return { "total": round(weighted_score * 100, 2), "detail": { "budget": round(budget_score * 100, 2), "preferensi": round(preference_score * 100, 2), "tahun": round(year_score * 100, 2), "mesin": round(engine_score * 100, 2), "cluster": round(cluster_score * 100, 2), }, "alasan": reasons[:5], } def default_weights(payload_weights): weights = { "budget": 0.35, "preference": 0.30, "year": 0.20, "engine": 0.10, "cluster": 0.05, } for key, value in (payload_weights or {}).items(): if key in weights: weights[key] = max(float(value), 0) total = sum(weights.values()) or 1 return {key: value / total for key, value in weights.items()} def recommendation_weights(payload_weights, strategy): if payload_weights: return default_weights(payload_weights) if normalize_text(strategy) in ["tahun_muda", "tahun muda", "kondisi_baru"]: return { "budget": 0.30, "preference": 0.10, "year": 0.40, "engine": 0.20, "cluster": 0.00, } return default_weights(None) def dataset_stats(cars): years = [car["tahun"] for car in cars if car["tahun"] > 0] clusters = [car["cluster"] for car in cars if car["cluster"] is not None] return { "min_year": min(years) if years else 0, "max_year": max(years) if years else 1, "median_cluster": median(clusters) if clusters else None, } def scaled_value(value, minimum, maximum): if maximum == minimum: return 0.0 return (value - minimum) / (maximum - minimum) def distance(point, centroid): return math.sqrt(sum((point[index] - centroid[index]) ** 2 for index in range(len(point)))) def car_features(car): return [car["harga"], car["tahun"], car["mesin_cc"]] def build_kmeans_model(cars, cluster_count=6, iterations=30): usable_cars = [car for car in cars if car["harga"] > 0 and car["tahun"] > 0 and car["mesin_cc"] > 0] if not usable_cars: return None feature_rows = [car_features(car) for car in usable_cars] columns = list(zip(*feature_rows)) ranges = [(min(column), max(column)) for column in columns] vectors = [ [scaled_value(value, ranges[index][0], ranges[index][1]) for index, value in enumerate(row)] for row in feature_rows ] cluster_count = max(1, min(cluster_count, len(vectors))) sorted_indexes = sorted(range(len(usable_cars)), key=lambda index: usable_cars[index]["harga"]) centroids = [] for cluster_index in range(cluster_count): sample_position = round(cluster_index * (len(sorted_indexes) - 1) / max(cluster_count - 1, 1)) centroids.append(vectors[sorted_indexes[sample_position]][:]) labels = [0 for _ in vectors] for _ in range(iterations): changed = False for index, vector in enumerate(vectors): nearest_cluster = min(range(cluster_count), key=lambda cluster: distance(vector, centroids[cluster])) if labels[index] != nearest_cluster: changed = True labels[index] = nearest_cluster new_centroids = [] for cluster in range(cluster_count): members = [vectors[index] for index, label in enumerate(labels) if label == cluster] if not members: new_centroids.append(centroids[cluster]) continue new_centroids.append( [sum(member[column] for member in members) / len(members) for column in range(len(vectors[0]))] ) centroids = new_centroids if not changed: break car_labels = {id(car): labels[index] for index, car in enumerate(usable_cars)} return { "cars": usable_cars, "labels": car_labels, "centroids": centroids, "ranges": ranges, "cluster_count": cluster_count, } def predict_budget_cluster(model, budget): model_cars = model["cars"] target_year = median([car["tahun"] for car in model_cars if car["harga"] <= budget] or [car["tahun"] for car in model_cars]) target_engine = median([car["mesin_cc"] for car in model_cars if car["harga"] <= budget] or [car["mesin_cc"] for car in model_cars]) target_features = [budget, target_year, target_engine] target_vector = [ scaled_value(value, model["ranges"][index][0], model["ranges"][index][1]) for index, value in enumerate(target_features) ] return min( range(model["cluster_count"]), key=lambda cluster: distance(target_vector, model["centroids"][cluster]), ) def apply_kmeans_budget_filter(cars, budget, filters): model = build_kmeans_model(cars, cluster_count=to_int(filters.get("jumlah_cluster"), default=4)) if not model: return cars, None target_cluster = predict_budget_cluster(model, budget) same_cluster = [ car for car in cars if model["labels"].get(id(car)) == target_cluster and car["harga"] <= budget ] exact_matches = [car for car in same_cluster if car["harga"] == budget] selected = exact_matches or same_cluster or [car for car in cars if car["harga"] <= budget] return selected, { "algorithm": "k_means", "features": ["harga", "tahun", "mesin_cc"], "cluster_count": model["cluster_count"], "target_cluster": target_cluster, "exact_budget_match": bool(exact_matches), } def recommend_by_budget( budget, limit=10, preferences=None, filters=None, strategy="seimbang", include_above_budget=False, max_over_budget_percent=0, weights=None, ): cars = load_cars() preferences = preferences or {} filters = filters or {} normalized_strategy = normalize_text(strategy) weights = recommendation_weights(weights, normalized_strategy) max_price = budget * (1 + max(max_over_budget_percent, 0) / 100) if include_above_budget else budget candidates = [car for car in cars if car["harga"] <= max_price and matches_filters(car, filters)] candidates, model_info = apply_kmeans_budget_filter(candidates, budget, filters) if normalized_strategy in ["harga_pas", "harga pas", "spesifik", "specific"]: tolerance_percent = to_int(filters.get("toleransi_harga_percent"), default=10) minimum_price = budget * (1 - max(tolerance_percent, 0) / 100) tight_candidates = [car for car in candidates if car["harga"] >= minimum_price] candidates = tight_candidates stats = dataset_stats(cars) recommendations = [] for car in candidates: scoring = score_car(car, budget, preferences, stats, normalized_strategy, weights) recommendations.append( { **car, "model_cluster": model_info["target_cluster"] if model_info else None, "selisih_budget": budget - car["harga"], "score": scoring["total"], "score_detail": scoring["detail"], "alasan": scoring["alasan"], } ) recommendations = sorted( recommendations, key=lambda car: (car["score"], car["tahun"], car["harga"]), reverse=True, )[:limit] return recommendations, model_info @app.get("/health") def health(): dataset_path = get_dataset_path() cars = load_cars() data_source = cars[0].get("source", "unknown") if cars else "empty" return jsonify( { "status": "ok", "source": data_source, "dataset": str(dataset_path) if dataset_path else None, "database": database_config()["database"], "total_data": len(cars), } ) @app.post("/recommend") def recommend(): payload = request.get_json(silent=True) or {} budget = to_int(payload.get("budget")) limit = to_int(payload.get("limit"), default=10) preferences, filters = parse_request_options(payload) if budget <= 0: return jsonify({"message": "Budget harus lebih dari 0."}), 422 if limit <= 0: limit = 10 recommendations = recommend_by_budget( budget=budget, limit=min(limit, 50), preferences=preferences, filters=filters, strategy=payload.get("strategy") or payload.get("strategi") or "seimbang", include_above_budget=bool(payload.get("include_above_budget", False)), max_over_budget_percent=to_int(payload.get("max_over_budget_percent"), default=0), weights=payload.get("weights") or payload.get("bobot"), ) recommendations, model_info = recommendations return jsonify( { "budget": budget, "total_rekomendasi": len(recommendations), "model": model_info, "preferences": preferences, "filters": filters, "recommendations": recommendations, } ) if __name__ == "__main__": app.run(host="127.0.0.1", port=5000, debug=False, use_reloader=False)