Spaces:
Sleeping
Sleeping
| # app/frontend.py | |
| # Author: Eugenia Tate | |
| # Date: 11/23/2025 | |
| # CITATION: | |
| # ChatGPT was used to prototype robust JSON-sanitization and input-coercion logic when encountering serialization errors | |
| # and mixed user inputs (strings, noisy numeric text, pandas / numpy scalars). A recursive approach and conversion patterns | |
| # were suggested; we reviewed and thoroughly tested the code locally. See coerce_and_clamp_dict() and make_json_safe() below. | |
| # import necessary helpers | |
| import os | |
| import yaml | |
| import json | |
| import math | |
| import pandas as pd, numpy as np # table handling | |
| import gradio as gr # UI | |
| import requests # to call API server | |
| from typing import Dict, Any, List | |
| # point to config.yaml file to retrieve API URL | |
| CONFIG_PATH = os.path.join(os.getcwd(), "config.yaml") | |
| # The above line was modified by ChatGPT 5.1 at 10:41a on 11/24/25 to work with Hugging Face | |
| # if config exists - load it | |
| if os.path.exists(CONFIG_PATH): | |
| with open(CONFIG_PATH, "r") as f: | |
| cfg = yaml.safe_load(f) | |
| # if config does not exist - it falls back to being an empty dict | |
| else: | |
| cfg = {} | |
| # server endpoint UI will use for POST; if confid is missing fallback to predict_named | |
| API_URL = cfg.get("api_url", {}).get("FastAPI", "http://127.0.0.1:8000/predict_named") | |
| # reduced set of sensible columns exposed in UI to the end user | |
| INPUT_COLS = [ | |
| "Aroma", "Flavor", "Aftertaste", "Acidity", | |
| "Body", "Balance", "Sweetness", "Clean.Cup" | |
| ] | |
| # help text for the end user explaining Clean Cup feature | |
| CLEAN_CUP_HELP = ( | |
| "Clean.Cup indicates the absence of off-flavors or defects (higher is better). " | |
| "Typically scored on the same sensory scale as other cup attributes." | |
| ) | |
| # enforcing 0 to 10 possible values for input | |
| RANGES = {c: (0.0, 10.0) for c in INPUT_COLS} | |
| # ------------------------------------ CITED BLOCK -------------------------------------------------------------------- | |
| # implemented using ChatGPT (conversation 2025-11-23) to help normalize free-form user input into numeric values within range | |
| # convert user values to allowed 0 - 10 range to avoid errors/crashes: handles blanks, strings, noisy input by stripping chars | |
| # and sets None for missing / invalid entries (JSON's null) | |
| def coerce_and_clamp_dict(row: Dict[str, Any]) -> Dict[str, Any]: | |
| # out = {} | |
| out: Dict[str, Any] = {} | |
| # iterates over 8 input columns | |
| for k in INPUT_COLS: | |
| v = row.get(k, "") | |
| # if a value user types is blank or string - converts it into np.nan | |
| # or if user types something like "7.5pts" it strips the letters and keeps the number | |
| if v is None or (isinstance(v, str) and v.strip() == ""): | |
| # out[k] = np.nan | |
| out[k] = None | |
| continue | |
| # tries to convert to float | |
| fv = None | |
| try: | |
| fv = float(v) | |
| except Exception: | |
| # try to strip out non-digit characters (e.g. "7.5pts" -> "7.5") | |
| try: | |
| cleaned = "".join(ch for ch in str(v) if (ch.isdigit() or ch in ".-")) | |
| fv = float(cleaned) if cleaned not in ("", ".", "-") else None | |
| except Exception: | |
| fv = None | |
| # if conversion failed -> None | |
| if fv is None or (isinstance(fv, float) and (math.isnan(fv) or math.isinf(fv))): | |
| out[k] = None | |
| continue | |
| # once we have a clean numeric - it is clamped to be within [0,10] range of valid inputs | |
| # if user typed 13 it will be clmaped to 10 | |
| # if user typed -2 it will become 0 | |
| lo, hi = RANGES.get(k, (None, None)) | |
| if lo is not None and hi is not None: | |
| fv = max(lo, min(hi, fv)) | |
| out[k] = float(fv) | |
| # returns a clean dict to be sent to server | |
| return out | |
| # ChatGPT 5.1 used to prototype this recursive JSON-sanitizer | |
| # This function recursively walks nested containers (dicts, lists, tuples) and ensures any nested | |
| # structure (e.g. {"payload": [{"Aroma": np.nan}]}) becomes JSON-safe everywhere, not just the top level | |
| def make_json_safe(obj): | |
| # dict | |
| if isinstance(obj, dict): | |
| return {k: make_json_safe(v) for k, v in obj.items()} | |
| # list/tuple | |
| if isinstance(obj, (list, tuple)): | |
| return [make_json_safe(v) for v in obj] | |
| # numpy scalar -> python scalar | |
| try: | |
| import numpy as _np | |
| if isinstance(obj, _np.generic): | |
| return make_json_safe(obj.item()) | |
| except Exception: | |
| pass | |
| # floats: map NaN/Inf -> None | |
| if isinstance(obj, float): | |
| if math.isnan(obj) or math.isinf(obj): | |
| return None | |
| return float(obj) | |
| # ints, bool, str, None: ok | |
| if isinstance(obj, (int, bool, str)) or obj is None: | |
| return obj | |
| # fallback | |
| try: | |
| return str(obj) | |
| except Exception: | |
| return None | |
| # ------------------------------------------ END CITED BLOCK ------------------------------------------------ | |
| # helper function that returns True if every value in a row is null or numeric 0, otherwise - False | |
| def _row_is_all_null_or_zero(row: Dict[str, Any]) -> bool: | |
| for v in row.values(): | |
| # missing/null -> keep scanning (counts as "no numeric input") | |
| if v is None: | |
| continue | |
| # numeric non-zero -> row is VALID | |
| if isinstance(v, (int, float)) and v != 0: | |
| return False | |
| # anything else (string, etc) is considered missing/invalid; continue | |
| # but coerce_and_clamp_dict should have converted those to None or numeric | |
| return True | |
| # sends JSON to server endpoint, returns a tuple (predictions list, raw resposnse/error) | |
| def call_api_named(payload_rows: List[Dict[str, Any]]): | |
| # sanitize payload so it's JSON-serializable and uses `null` for missing | |
| safe_body = {"rows": make_json_safe(payload_rows)} | |
| try: | |
| payload_str = json.dumps(safe_body) | |
| except Exception as e: | |
| return None, f"Serialization error: {e}" | |
| # tries calling POST to get predictions using requests lib | |
| headers = {"Content-Type": "application/json"} | |
| try: | |
| response = requests.post(API_URL, data=payload_str, headers=headers, timeout=10) # timeout at 10 sec to avoid hanging | |
| response.raise_for_status() | |
| # returns prediction list and full raw text response to be used within debug box on SUCCESS (200 OK) | |
| return response.json().get("predictions", []), response.text | |
| except Exception as e: | |
| return None, f"API error: {e}" # on error return None | |
| #prettifies prediction and debug JSON | |
| def predict_from_rows_of_dicts(rows_of_dicts: List[Dict[str, Any]]): | |
| payload_rows = [coerce_and_clamp_dict(row) for row in rows_of_dicts] | |
| # decide whether submission is allowed: | |
| # - if every submitted row is all-null-or-zero, refuse | |
| all_rows_invalid = all(_row_is_all_null_or_zero(r) for r in payload_rows) | |
| if all_rows_invalid: | |
| debug = {"payload": payload_rows, "response_raw": "skipped - all values missing or zero"} | |
| return "Please enter at least one numeric attribute (non-zero) before submitting.", json.dumps(debug, indent=2) | |
| # Otherwise proceed and call API (allowed if at least one row has a non-zero numeric) | |
| preds, raw = call_api_named(payload_rows) | |
| # building a debug dictionary containing both payload and raw server response | |
| debug = {"payload": payload_rows, "response_raw": raw} | |
| # if API fails - return empty prediction and debug JSON for debugging | |
| if preds is None: | |
| return "", json.dumps(debug, indent=2) | |
| # prettifying predictions upon successful call to be user-friendly | |
| prettified_pred = [f"Predicted Coffee Quality Points = {round(float(p), 1)}" for p in preds] # rounding predictions to 1 decimal place (user friendly) | |
| #returns prettified prediction and debug JSON for debug box | |
| return "\n".join(prettified_pred), json.dumps(debug, indent=2) | |
| def predict_from_table(table): | |
| rows_of_dicts = table_to_list_of_dicts(table) | |
| return predict_from_rows_of_dicts(rows_of_dicts) | |
| # ------------------------------------ CITED BLOCK ------------------------------------- | |
| # ChatGPT was used on 11/23/2025 to fix this function due to encountering errors to help deal | |
| # with 2 possible incoming formats: Dataframe and list of lists. | |
| # helper function puts input into proper expected by server format of list-of-dicts keyed by INPUT_COLS: | |
| # [{"Aroma": 7.5, "Flavor": 8.0, ...}]; | |
| # fills missing columns with empty strings so coerce_and_clamp_dict() can convert them to np.nan | |
| def table_to_list_of_dicts(table): | |
| # if table passed in is an instance of Dataframe obj - turn it into a dict | |
| if isinstance(table, pd.DataFrame): | |
| df = table | |
| return [df.iloc[i].to_dict() for i in range(len(df))] | |
| # else - assume table is a list of lists and manually pair each element to corresponding column | |
| rows = [] | |
| for row in table: | |
| # ensure row has right length | |
| vals = list(row) + [""] * max(0, len(INPUT_COLS) - len(row)) | |
| rows.append({col: vals[i] for i, col in enumerate(INPUT_COLS)}) | |
| return rows | |
| # ------------------------------- END CITED BLOCK ------------------------------------------- | |
| # -------------------------------- Gradio UI ------------------------------------------------------ | |
| with gr.Blocks(title="Coffee Quality Points Estimator") as demo: | |
| # inline HTML/CSS to style user instructions | |
| gr.Markdown("<h1 style='text-align:center;color:#08306B'>Coffee Quality Points Estimator</h1>") | |
| gr.Markdown( | |
| "<div style='font-size:17px;font-weight:700;color:#2b6cb0'>" | |
| "Instructions: Fill the known sensory attributes (0–10). Leave unknowns blank and the model will " | |
| "attempt to infer missing values. Then click <b style='color:#ff6600'>Submit</b> to estimate the " | |
| "<b>Coffee Quality Points</b> (Total.Cup.Points). Higher scores mean better coffee quality.</div>" | |
| ) | |
| with gr.Row(): | |
| # presents 1 row by default with INPUT_COLS | |
| df_input = gr.Dataframe( | |
| headers=INPUT_COLS, | |
| value=[["" for _ in INPUT_COLS]], # list of lists to avoid validation errors encountered on testing | |
| # ------------------------- ChatGPT 5.1 was used to fix the issues on 11/23/2025 --------------------- | |
| row_count=1, | |
| col_count=len(INPUT_COLS), | |
| interactive=True, | |
| label="Enter Known Columns (0–10 range; numeric values preferred)" | |
| ) | |
| with gr.Row(): | |
| submit_btn = gr.Button("Submit", variant="primary") | |
| with gr.Row(): | |
| # short prediction for the user | |
| pred_out = gr.Textbox(label="Predicted Coffee Quality Points", lines=1, interactive=False) | |
| with gr.Row(): | |
| # full debug info for developer | |
| debug_out = gr.Textbox(label="Debug (payload + raw response)", lines=10, interactive=False) | |
| with gr.Row(): | |
| gr.Markdown(f"<b>Note:</b> <i>{CLEAN_CUP_HELP}</i>") | |
| # When user clicks Submit, Gradio sends the contents of the table to table_to_list_of_dicts(). | |
| # the content can either be a Dataframe or list of lists and the helper function can handle both | |
| # making the format consistent with FastAPI expectations | |
| def submit_table(table): | |
| rows_of_dicts = table_to_list_of_dicts(table) | |
| return predict_from_rows_of_dicts(rows_of_dicts) | |
| # fires up the actual prediction | |
| submit_btn.click(predict_from_table, inputs=[df_input], outputs=[pred_out, debug_out]) | |
| if __name__ == "__main__": | |
| # auto opens the demo in browser | |
| demo.launch() |