getting a bit further with the frontend and demoing the analysis to matti
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
# %% [markdown]
|
||||
# # Visualize Most Prepared Users
|
||||
# This workflow loads user embeddings, generates a prompt embedding, computes similarity, and visualizes the most prepared users.
|
||||
|
||||
# %%
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.manifold import TSNE
|
||||
import plotly.express as px
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import umap
|
||||
import umap.plot
|
||||
from utils import get_ollama_embedding
|
||||
|
||||
# %% [markdown]
|
||||
# ## Read in the file with user vectors
|
||||
# Update the path/format as needed.
|
||||
# %%
|
||||
df = (
|
||||
pd.read_csv("./data/eurobarometer_preparedness_model_data_v3.csv")
|
||||
.reset_index()
|
||||
.assign(
|
||||
**{
|
||||
"user_id": lambda x: x["index"].astype(str)
|
||||
+ "_"
|
||||
+ x["country_code_iso_3166"]
|
||||
+ "_"
|
||||
+ x["age_recoded_6_categories"].astype(str)
|
||||
+ "_"
|
||||
+ x["gender"].astype(str)
|
||||
}
|
||||
)
|
||||
# select only relevant countries for visualization
|
||||
.loc[
|
||||
lambda x: x["country_code_iso_3166"].isin(
|
||||
["FI", "DE-E", "DE-W", "FR", "ES", "PT"]
|
||||
)
|
||||
]
|
||||
)
|
||||
df.shape
|
||||
|
||||
# relabel None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say to other
|
||||
df["gender"] = df["gender"].replace({
|
||||
"None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say": "Other",
|
||||
})
|
||||
|
||||
# %% [markdown]
|
||||
# ## Convert string embeddings to lists if needed
|
||||
# %%
|
||||
def parse_embedding(x):
|
||||
if isinstance(x, str):
|
||||
return [float(i) for i in x.strip("[]").split(",")]
|
||||
return x
|
||||
|
||||
|
||||
df["embedding"] = df["embedding"].apply(parse_embedding)
|
||||
|
||||
# %%
|
||||
# Select best user:
|
||||
# disaster_measures_in_hh_* == 1, and how_many_days_meet_* == (4 | 5)
|
||||
# Following relabeling in data_preparation_raw.py:
|
||||
# disaster_measures_in_hh_* != "Not mentioned"
|
||||
# how_many_days_meet_* == "More than 7 days"
|
||||
disaster_measures_in_hh_columns = [
|
||||
col for col in df.columns if col.startswith("disaster_measures_in_hh_")
|
||||
]
|
||||
how_many_days_meet_columns = [
|
||||
col for col in df.columns if col.startswith("how_many_days_meet_")
|
||||
]
|
||||
best_users = df[
|
||||
(df[disaster_measures_in_hh_columns] != "Not mentioned").all(axis=1)
|
||||
& (df[how_many_days_meet_columns] == "More than 7 days").all(axis=1)
|
||||
]
|
||||
print(f"Number of best users: {len(best_users)}")
|
||||
|
||||
best_users.head(5)[["user_id", "user_text"]].to_dict(orient="records")
|
||||
|
||||
# %%
|
||||
best_users.columns.tolist()
|
||||
|
||||
# %% [markdown]
|
||||
# ## Write your prompt and generate its embedding
|
||||
# %%
|
||||
# prompt = "The user is highly prepared for disasters, with emergency supplies and a clear plan."
|
||||
prompt = best_users.head(1)["user_text"].values[
|
||||
0
|
||||
] # Example: use the first user's text as the prompt
|
||||
prompt_embedding = get_ollama_embedding(prompt)
|
||||
|
||||
# %% [markdown]
|
||||
# ## Compute similarity between each user and the prompt
|
||||
# %%
|
||||
user_embeddings = np.vstack(df["embedding"].values)
|
||||
prompt_vec = np.array(prompt_embedding).reshape(1, -1)
|
||||
similarities = cosine_similarity(user_embeddings, prompt_vec).flatten()
|
||||
df["similarity"] = similarities
|
||||
|
||||
# %% [markdown]
|
||||
# ## Visualize the users by similarity
|
||||
# %%
|
||||
_ = plt.figure(figsize=(5, 7))
|
||||
_ = sns.violinplot(
|
||||
x="similarity", y="country_code_iso_3166", hue="country_code_iso_3166", data=df
|
||||
)
|
||||
_ = sns.stripplot(
|
||||
x="similarity",
|
||||
y="country_code_iso_3166",
|
||||
data=df,
|
||||
hue="country_code_iso_3166",
|
||||
alpha=0.8,
|
||||
jitter=True,
|
||||
linewidth=0.5,
|
||||
edgecolor="white",
|
||||
)
|
||||
_ = plt.title("Distribution of User Similarities to Preparedness Prompt")
|
||||
_ = plt.xlabel("Cosine Similarity")
|
||||
|
||||
# %%
|
||||
# np.savetxt("./data/embeddings.tsv", np.vstack(df["embedding"].values), delimiter="\t")
|
||||
|
||||
# # %%
|
||||
# df[["user_id"]].to_csv(
|
||||
# "./data/metadata.tsv", sep="\t", index=False
|
||||
# )
|
||||
|
||||
# %%
|
||||
# Reduce embeddings to 3D with PCA
|
||||
pca = PCA(n_components=3)
|
||||
embeddings_3d = pca.fit_transform(np.vstack(df["embedding"].values))
|
||||
|
||||
# Add PCA components to dataframe
|
||||
df["pca1"] = embeddings_3d[:, 0]
|
||||
df["pca2"] = embeddings_3d[:, 1]
|
||||
df["pca3"] = embeddings_3d[:, 2]
|
||||
|
||||
# %%
|
||||
# Interactive 3D scatter plot
|
||||
fig = px.scatter_3d(
|
||||
df,
|
||||
x="pca1",
|
||||
y="pca2",
|
||||
z="pca3",
|
||||
color="age_recoded_6_categories",
|
||||
hover_data=["user_id", "gender", "age_recoded_6_categories"],
|
||||
title="User Embeddings (PCA 3D)"
|
||||
)
|
||||
fig.write_html("./output/user_embeddings_pca_3d.html")
|
||||
# %%
|
||||
# Reduce embeddings to 3D with t-SNE
|
||||
tsne = TSNE(n_components=3, random_state=42, perplexity=30)
|
||||
embeddings_3d = tsne.fit_transform(np.vstack(df["embedding"].values))
|
||||
|
||||
# Add t-SNE components to dataframe
|
||||
df["tsne1"] = embeddings_3d[:, 0]
|
||||
df["tsne2"] = embeddings_3d[:, 1]
|
||||
df["tsne3"] = embeddings_3d[:, 2]
|
||||
|
||||
# Interactive 3D scatter plot
|
||||
fig = px.scatter_3d(
|
||||
df,
|
||||
x="tsne1",
|
||||
y="tsne2",
|
||||
z="tsne3",
|
||||
color="age_recoded_6_categories",
|
||||
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
||||
title="User Embeddings (t-SNE 3D)"
|
||||
)
|
||||
fig.write_html("./output/user_embeddings_tsne_3d.html")
|
||||
|
||||
# %%
|
||||
# example comparison based on t-sne projection
|
||||
index_1 = 9561
|
||||
index_2 = 5096
|
||||
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
||||
# %%
|
||||
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
||||
|
||||
diffs = {}
|
||||
for col in column_list:
|
||||
val1 = row1[col]
|
||||
val2 = row2[col]
|
||||
if pd.isnull(val1) and pd.isnull(val2):
|
||||
continue
|
||||
if val1 != val2:
|
||||
diffs[col] = (val1, val2)
|
||||
|
||||
# Print or display the differing columns and their values
|
||||
for col, (v1, v2) in diffs.items():
|
||||
print(f"{col}: {v1} | {v2}")
|
||||
# %%
|
||||
# Reduce embeddings to 3D with UMAP
|
||||
umap_3d = umap.UMAP(n_components=3, random_state=42, metric="cosine").fit_transform(np.vstack(df["embedding"].values))
|
||||
|
||||
# Add UMAP components to dataframe
|
||||
df["umap1"] = umap_3d[:, 0]
|
||||
df["umap2"] = umap_3d[:, 1]
|
||||
df["umap3"] = umap_3d[:, 2]
|
||||
|
||||
# Interactive 3D scatter plot
|
||||
fig = px.scatter_3d(
|
||||
df,
|
||||
x="umap1",
|
||||
y="umap2",
|
||||
z="umap3",
|
||||
color="country_code_iso_3166",
|
||||
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
||||
title="User Embeddings (UMAP 3D)"
|
||||
)
|
||||
fig.write_html("./output/user_embeddings_umap_3d.html")
|
||||
|
||||
# %%
|
||||
# example comparison based on UMAP projection
|
||||
index_1 = 21396
|
||||
index_2 = 10444
|
||||
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
||||
# %%
|
||||
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
||||
diffs = {}
|
||||
for col in column_list:
|
||||
val1 = row1[col]
|
||||
val2 = row2[col]
|
||||
if pd.isnull(val1) and pd.isnull(val2):
|
||||
continue
|
||||
if val1 != val2:
|
||||
diffs[col] = (val1, val2)
|
||||
|
||||
# Print or display the differing columns and their values
|
||||
for col, (v1, v2) in diffs.items():
|
||||
print(f"{col}: {v1} | {v2}")
|
||||
# %%
|
||||
File diff suppressed because one or more lines are too long
+15
-231
@@ -1,236 +1,20 @@
|
||||
# %% [markdown]
|
||||
# # Visualize Most Prepared Users
|
||||
# This workflow loads user embeddings, generates a prompt embedding, computes similarity, and visualizes the most prepared users.
|
||||
import logging
|
||||
import uvicorn
|
||||
import os
|
||||
from api import app
|
||||
|
||||
# %%
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
from sklearn.decomposition import PCA
|
||||
from sklearn.manifold import TSNE
|
||||
import plotly.express as px
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import umap
|
||||
import umap.plot
|
||||
from utils import get_ollama_embedding
|
||||
|
||||
# %% [markdown]
|
||||
# ## Read in the file with user vectors
|
||||
# Update the path/format as needed.
|
||||
# %%
|
||||
df = (
|
||||
pd.read_csv("./data/eurobarometer_preparedness_model_data_v3.csv")
|
||||
.reset_index()
|
||||
.assign(
|
||||
**{
|
||||
"user_id": lambda x: x["index"].astype(str)
|
||||
+ "_"
|
||||
+ x["country_code_iso_3166"]
|
||||
+ "_"
|
||||
+ x["age_recoded_6_categories"].astype(str)
|
||||
+ "_"
|
||||
+ x["gender"].astype(str)
|
||||
}
|
||||
)
|
||||
# select only relevant countries for visualization
|
||||
.loc[
|
||||
lambda x: x["country_code_iso_3166"].isin(
|
||||
["FI", "DE-E", "DE-W", "FR", "ES", "PT"]
|
||||
)
|
||||
]
|
||||
)
|
||||
df.shape
|
||||
|
||||
# relabel None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say to other
|
||||
df["gender"] = df["gender"].replace({
|
||||
"None of the above/ Non binary/ do not recognize yourself in above categories/Prefer not to say": "Other",
|
||||
})
|
||||
|
||||
# %% [markdown]
|
||||
# ## Convert string embeddings to lists if needed
|
||||
# %%
|
||||
def parse_embedding(x):
|
||||
if isinstance(x, str):
|
||||
return [float(i) for i in x.strip("[]").split(",")]
|
||||
return x
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
df["embedding"] = df["embedding"].apply(parse_embedding)
|
||||
def run_api() -> None:
|
||||
"""Run the FastAPI application."""
|
||||
if "james" in os.environ.get("USER", ""):
|
||||
logger.info("Running in James's environment")
|
||||
uvicorn.run("api:app", host="0.0.0.0", port=8080, reload=True)
|
||||
else:
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
|
||||
# %%
|
||||
# Select best user:
|
||||
# disaster_measures_in_hh_* == 1, and how_many_days_meet_* == (4 | 5)
|
||||
# Following relabeling in data_preparation_raw.py:
|
||||
# disaster_measures_in_hh_* != "Not mentioned"
|
||||
# how_many_days_meet_* == "More than 7 days"
|
||||
disaster_measures_in_hh_columns = [
|
||||
col for col in df.columns if col.startswith("disaster_measures_in_hh_")
|
||||
]
|
||||
how_many_days_meet_columns = [
|
||||
col for col in df.columns if col.startswith("how_many_days_meet_")
|
||||
]
|
||||
best_users = df[
|
||||
(df[disaster_measures_in_hh_columns] != "Not mentioned").all(axis=1)
|
||||
& (df[how_many_days_meet_columns] == "More than 7 days").all(axis=1)
|
||||
]
|
||||
print(f"Number of best users: {len(best_users)}")
|
||||
|
||||
best_users.head(5)[["user_id", "user_text"]].to_dict(orient="records")
|
||||
|
||||
# %% [markdown]
|
||||
# ## Write your prompt and generate its embedding
|
||||
# %%
|
||||
# prompt = "The user is highly prepared for disasters, with emergency supplies and a clear plan."
|
||||
prompt = best_users.head(1)["user_text"].values[
|
||||
0
|
||||
] # Example: use the first user's text as the prompt
|
||||
prompt_embedding = get_ollama_embedding(prompt)
|
||||
|
||||
# %% [markdown]
|
||||
# ## Compute similarity between each user and the prompt
|
||||
# %%
|
||||
user_embeddings = np.vstack(df["embedding"].values)
|
||||
prompt_vec = np.array(prompt_embedding).reshape(1, -1)
|
||||
similarities = cosine_similarity(user_embeddings, prompt_vec).flatten()
|
||||
df["similarity"] = similarities
|
||||
|
||||
# %% [markdown]
|
||||
# ## Visualize the users by similarity
|
||||
# %%
|
||||
_ = plt.figure(figsize=(5, 7))
|
||||
_ = sns.violinplot(
|
||||
x="similarity", y="country_code_iso_3166", hue="country_code_iso_3166", data=df
|
||||
)
|
||||
_ = sns.stripplot(
|
||||
x="similarity",
|
||||
y="country_code_iso_3166",
|
||||
data=df,
|
||||
hue="country_code_iso_3166",
|
||||
alpha=0.8,
|
||||
jitter=True,
|
||||
linewidth=0.5,
|
||||
edgecolor="white",
|
||||
)
|
||||
_ = plt.title("Distribution of User Similarities to Preparedness Prompt")
|
||||
_ = plt.xlabel("Cosine Similarity")
|
||||
|
||||
# %%
|
||||
# np.savetxt("./data/embeddings.tsv", np.vstack(df["embedding"].values), delimiter="\t")
|
||||
|
||||
# # %%
|
||||
# df[["user_id"]].to_csv(
|
||||
# "./data/metadata.tsv", sep="\t", index=False
|
||||
# )
|
||||
|
||||
# %%
|
||||
# Reduce embeddings to 3D with PCA
|
||||
pca = PCA(n_components=3)
|
||||
embeddings_3d = pca.fit_transform(np.vstack(df["embedding"].values))
|
||||
|
||||
# Add PCA components to dataframe
|
||||
df["pca1"] = embeddings_3d[:, 0]
|
||||
df["pca2"] = embeddings_3d[:, 1]
|
||||
df["pca3"] = embeddings_3d[:, 2]
|
||||
|
||||
# %%
|
||||
# Interactive 3D scatter plot
|
||||
fig = px.scatter_3d(
|
||||
df,
|
||||
x="pca1",
|
||||
y="pca2",
|
||||
z="pca3",
|
||||
color="age_recoded_6_categories",
|
||||
hover_data=["user_id", "gender", "age_recoded_6_categories"],
|
||||
title="User Embeddings (PCA 3D)"
|
||||
)
|
||||
fig.write_html("./output/user_embeddings_pca_3d.html")
|
||||
# %%
|
||||
# Reduce embeddings to 3D with t-SNE
|
||||
tsne = TSNE(n_components=3, random_state=42, perplexity=30)
|
||||
embeddings_3d = tsne.fit_transform(np.vstack(df["embedding"].values))
|
||||
|
||||
# Add t-SNE components to dataframe
|
||||
df["tsne1"] = embeddings_3d[:, 0]
|
||||
df["tsne2"] = embeddings_3d[:, 1]
|
||||
df["tsne3"] = embeddings_3d[:, 2]
|
||||
|
||||
# Interactive 3D scatter plot
|
||||
fig = px.scatter_3d(
|
||||
df,
|
||||
x="tsne1",
|
||||
y="tsne2",
|
||||
z="tsne3",
|
||||
color="age_recoded_6_categories",
|
||||
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
||||
title="User Embeddings (t-SNE 3D)"
|
||||
)
|
||||
fig.write_html("./output/user_embeddings_tsne_3d.html")
|
||||
|
||||
# %%
|
||||
# example comparison based on t-sne projection
|
||||
index_1 = 9561
|
||||
index_2 = 5096
|
||||
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
||||
# %%
|
||||
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3"])
|
||||
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
||||
|
||||
diffs = {}
|
||||
for col in column_list:
|
||||
val1 = row1[col]
|
||||
val2 = row2[col]
|
||||
if pd.isnull(val1) and pd.isnull(val2):
|
||||
continue
|
||||
if val1 != val2:
|
||||
diffs[col] = (val1, val2)
|
||||
|
||||
# Print or display the differing columns and their values
|
||||
for col, (v1, v2) in diffs.items():
|
||||
print(f"{col}: {v1} | {v2}")
|
||||
# %%
|
||||
# Reduce embeddings to 3D with UMAP
|
||||
umap_3d = umap.UMAP(n_components=3, random_state=42, metric="cosine").fit_transform(np.vstack(df["embedding"].values))
|
||||
|
||||
# Add UMAP components to dataframe
|
||||
df["umap1"] = umap_3d[:, 0]
|
||||
df["umap2"] = umap_3d[:, 1]
|
||||
df["umap3"] = umap_3d[:, 2]
|
||||
|
||||
# Interactive 3D scatter plot
|
||||
fig = px.scatter_3d(
|
||||
df,
|
||||
x="umap1",
|
||||
y="umap2",
|
||||
z="umap3",
|
||||
color="country_code_iso_3166",
|
||||
hover_data=["user_id", "gender", "country_code_iso_3166"],
|
||||
title="User Embeddings (UMAP 3D)"
|
||||
)
|
||||
fig.write_html("./output/user_embeddings_umap_3d.html")
|
||||
|
||||
# %%
|
||||
# example comparison based on UMAP projection
|
||||
index_1 = 21396
|
||||
index_2 = 10444
|
||||
df.loc[[index_1, index_2]][["user_id", "user_text", "similarity"]].to_dict(orient="records")
|
||||
# %%
|
||||
row1 = df.loc[index_1].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||
row2 = df.loc[index_2].drop(labels=["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||
# column_list = df.columns.difference(["user_id", "embedding", "user_text", "similarity", "pca1", "pca2", "pca3", "tsne1", "tsne2", "tsne3", "umap1", "umap2", "umap3"])
|
||||
column_list = disaster_measures_in_hh_columns + how_many_days_meet_columns
|
||||
diffs = {}
|
||||
for col in column_list:
|
||||
val1 = row1[col]
|
||||
val2 = row2[col]
|
||||
if pd.isnull(val1) and pd.isnull(val2):
|
||||
continue
|
||||
if val1 != val2:
|
||||
diffs[col] = (val1, val2)
|
||||
|
||||
# Print or display the differing columns and their values
|
||||
for col, (v1, v2) in diffs.items():
|
||||
print(f"{col}: {v1} | {v2}")
|
||||
# %%
|
||||
if __name__ == "__main__":
|
||||
run_api()
|
||||
@@ -8,3 +8,5 @@ pyreadstat
|
||||
plotly
|
||||
nbformat>=4.2.0
|
||||
umap-learn[plot]
|
||||
fastapi
|
||||
uvicorn
|
||||
@@ -0,0 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PredictionRequest(BaseModel):
|
||||
text: str
|
||||
@@ -0,0 +1,757 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Preparedness Survey — Quick Form</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 28px;
|
||||
background: #f7fafc
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 28px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 20px rgba(2, 6, 23, .08)
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 18px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
select,
|
||||
textarea,
|
||||
input[type=number] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* form rows: labels above inputs for cleaner reading and to avoid overlap */
|
||||
.form-row {
|
||||
display: block;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #eef2f6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* label shown above the input area */
|
||||
.form-row>label,
|
||||
.label-top {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.form-row .input-wrap {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* keep Likert rows visually compact with the question label above */
|
||||
.form-row.likert-row {
|
||||
display: block
|
||||
}
|
||||
|
||||
.form-row.likert-row .input-wrap.likert {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap
|
||||
}
|
||||
|
||||
@media (max-width:700px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.form-row .input-wrap {
|
||||
width: 100%
|
||||
}
|
||||
}
|
||||
|
||||
.likert {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap
|
||||
}
|
||||
|
||||
/* each option is an inline radio + text so the options form a horizontal row */
|
||||
.likert label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px
|
||||
}
|
||||
|
||||
.likert input[type=radio] {
|
||||
margin: 0 6px 0 0
|
||||
}
|
||||
|
||||
/* Normalize likert groups so all questions display consistently */
|
||||
.input-wrap.likert {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-wrap.likert .likert,
|
||||
.input-wrap.likert {
|
||||
width: 100%;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.input-wrap.likert .likert {
|
||||
justify-content: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.input-wrap.likert label {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: transparent
|
||||
}
|
||||
|
||||
.input-wrap.likert label:hover {
|
||||
background: rgba(2, 6, 23, 0.03)
|
||||
}
|
||||
|
||||
.input-wrap.likert input[type=radio] {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* toggle switch for binary yes/no selects */
|
||||
.toggle-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
display: none
|
||||
}
|
||||
|
||||
.toggle-switch .slider {
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
transition: background .18s ease
|
||||
}
|
||||
|
||||
.toggle-switch .slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform .18s ease
|
||||
}
|
||||
|
||||
.toggle-switch input:checked+.slider {
|
||||
background: #10b981
|
||||
}
|
||||
|
||||
.toggle-switch input:checked+.slider::after {
|
||||
transform: translateX(22px)
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
background: #0069ff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0b1220;
|
||||
color: #e6eef8;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: auto
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 28px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 18px;
|
||||
color: #0f172a
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 18px;
|
||||
color: #374151
|
||||
}
|
||||
|
||||
small {
|
||||
color: #6b7280
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Preparedness survey — quick form</h1>
|
||||
<p>Fill the fields you have, then use <strong>Predict</strong> to send the form as JSON to the server's
|
||||
<code>/predict</code> endpoint and see the model response below.
|
||||
</p>
|
||||
<form id="survey">
|
||||
<!-- dynamic form will be rendered here from metadata -->
|
||||
<div id="dynamic-grid" class="grid"></div>
|
||||
|
||||
<!-- All static questions removed. The form is fully metadata-driven and rendered into #dynamic-grid -->
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="predict">Predict</button>
|
||||
<button type="button" id="selftest">Self-test</button>
|
||||
</div>
|
||||
|
||||
<h3>JSON preview</h3>
|
||||
<pre id="output">{ }</pre>
|
||||
</form>
|
||||
<script>
|
||||
// Note: selects were converted to static markup; label/likert styling is handled in the CSS above.
|
||||
const form = document.getElementById('survey');
|
||||
const output = document.getElementById('output');
|
||||
|
||||
function collectForm() {
|
||||
const data = {};
|
||||
// include all inputs/selects
|
||||
const elements = Array.from(form.elements).filter(e => e.name);
|
||||
// handle radios by grouping
|
||||
const handled = new Set();
|
||||
elements.forEach(el => {
|
||||
const name = el.name;
|
||||
if (handled.has(name)) return;
|
||||
if (el.type === 'radio') {
|
||||
const radios = form.querySelectorAll(`input[name="${name}"]`);
|
||||
const checked = Array.from(radios).find(r => r.checked);
|
||||
data[name] = checked ? checked.value : null;
|
||||
handled.add(name);
|
||||
return;
|
||||
}
|
||||
if (el.type === 'checkbox') {
|
||||
// if this checkbox was created as a toggle with data-yes/data-no, return the string
|
||||
const yes = el.dataset && el.dataset.yes;
|
||||
const no = el.dataset && el.dataset.no;
|
||||
if (yes !== undefined && no !== undefined) {
|
||||
data[name] = el.checked ? yes : no;
|
||||
} else {
|
||||
data[name] = el.checked ? 1 : 0;
|
||||
}
|
||||
handled.add(name);
|
||||
return;
|
||||
}
|
||||
if (el.type === 'number' || el.type === 'range') {
|
||||
const v = el.value;
|
||||
data[name] = v === '' ? null : Number(v);
|
||||
handled.add(name);
|
||||
return;
|
||||
}
|
||||
// select, text, textarea, etc.
|
||||
data[name] = el.value === '' ? null : el.value;
|
||||
handled.add(name);
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// Map compact option values to human-friendly labels used in the canonical example
|
||||
const VALUE_LABELS = {
|
||||
// general likert / frequency
|
||||
'very_much': 'A great deal',
|
||||
'somewhat': 'Somewhat',
|
||||
'not_much': 'Not much',
|
||||
'not_at_all': 'Not at all',
|
||||
|
||||
'strongly_agree': 'Strongly agree',
|
||||
'agree': 'Agree',
|
||||
'neutral': 'Neutral',
|
||||
'disagree': 'Disagree',
|
||||
'strongly_disagree': 'Strongly disagree',
|
||||
|
||||
'very_trust': 'Very trust',
|
||||
'somewhat_trust': 'Somewhat trust',
|
||||
'somewhat_distrust': 'Somewhat distrust',
|
||||
'very_distrust': 'Very distrust',
|
||||
|
||||
'daily': 'Daily',
|
||||
'weekly': 'Weekly',
|
||||
'monthly': 'Monthly',
|
||||
'rarely': 'Rarely',
|
||||
'never': 'Never',
|
||||
|
||||
'female': 'Female',
|
||||
'male': 'Male',
|
||||
'other': 'Other',
|
||||
'prefer_not': 'Prefer not to say',
|
||||
|
||||
'getting_better': 'Getting better',
|
||||
'staying_same': 'Staying the same',
|
||||
'getting_worse': 'Getting worse',
|
||||
|
||||
'often': 'Often',
|
||||
'sometimes': 'Sometimes',
|
||||
|
||||
'improved': 'Improved',
|
||||
'same': 'Same',
|
||||
'worse': 'Worse',
|
||||
'better': 'Better',
|
||||
|
||||
'rural': 'Rural',
|
||||
'small_town': 'Small town',
|
||||
'suburb': 'Suburb',
|
||||
'city': 'City',
|
||||
|
||||
// toggles / yes-no
|
||||
'yes': 'Yes',
|
||||
'no': 'No'
|
||||
};
|
||||
|
||||
// SERVER_VALUE_LABELS will hold the variable-level mappings returned from the server
|
||||
// structure: { varName: { codeStr: label, ... }, ... }
|
||||
let SERVER_VALUE_LABELS = {};
|
||||
// FIELD_LABELS maps our stable field name (input.name) -> human-friendly label
|
||||
// FIELD_INTERNALS maps stable field name -> server-provided internal_label (snake_case id)
|
||||
// populated when we render the variables list so we can use internal_label when sending
|
||||
// the combined text (and still show human-friendly labels in the UI).
|
||||
let FIELD_LABELS = {};
|
||||
let FIELD_INTERNALS = {};
|
||||
// FIELD_VARNAMES maps stable field name -> canonical variable id (as returned in variables[].id)
|
||||
// we need this so we can look up SERVER_VALUE_LABELS by the canonical id when resolving labels
|
||||
let FIELD_VARNAMES = {};
|
||||
// VAR_VALUES stores the values map returned in /variables for each canonical var id
|
||||
// structure: { varId: { code: label, ... }, ... }
|
||||
let VAR_VALUES = {};
|
||||
|
||||
// Load server-provided variable metadata and render the entire form from it.
|
||||
(async function loadVariablesAndRender() {
|
||||
try {
|
||||
// Load variable metadata (ordered list)
|
||||
const varsResp = await fetch('/variables');
|
||||
if (!varsResp.ok) throw new Error('Could not load /variables');
|
||||
const variables = await varsResp.json();
|
||||
|
||||
// load server-side value labels for quick lookup too
|
||||
const vlResp = await fetch('/value_labels');
|
||||
if (vlResp.ok) {
|
||||
SERVER_VALUE_LABELS = await vlResp.json();
|
||||
}
|
||||
|
||||
const grid = document.getElementById('dynamic-grid');
|
||||
grid.innerHTML = '';
|
||||
console.log('[survey] Loaded', variables.length, 'variables from /variables');
|
||||
|
||||
// heuristic: certain variable name prefixes indicate a range/numeric field
|
||||
function looksLikeRange(varId) {
|
||||
return varId.startsWith('how_many_days') || varId.startsWith('age_') || varId.startsWith('age') || varId.startsWith('age_recoded');
|
||||
}
|
||||
|
||||
variables.forEach(v => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'form-row';
|
||||
|
||||
const lbl = document.createElement('label');
|
||||
lbl.textContent = v.label || v.id;
|
||||
row.appendChild(lbl);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'input-wrap';
|
||||
// stable field name for submission: use snake_case of id
|
||||
const fieldName = String(v.id).toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||
wrap.setAttribute('data-field', fieldName);
|
||||
wrap.setAttribute('data-var', v.id);
|
||||
// remember the human label and internal_label for display and submission
|
||||
FIELD_LABELS[fieldName] = v.label || v.id;
|
||||
FIELD_INTERNALS[fieldName] = v.internal_label || fieldName;
|
||||
FIELD_VARNAMES[fieldName] = v.id;
|
||||
VAR_VALUES[v.id] = v.values || {};
|
||||
console.log('[survey] FIELD_LABELS set:', fieldName, '=>', FIELD_LABELS[fieldName]);
|
||||
console.log('[survey] FIELD_INTERNALS set:', fieldName, '=>', FIELD_INTERNALS[fieldName]);
|
||||
|
||||
// if we have value labels for this var, render radios or toggles
|
||||
const vals = v.values || {};
|
||||
const codes = Object.keys(vals || {});
|
||||
if (codes.length === 0) {
|
||||
// no canonical values: choose text or range based on heuristics
|
||||
if (looksLikeRange(v.id)) {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'range';
|
||||
inp.name = fieldName;
|
||||
inp.id = fieldName;
|
||||
inp.min = 0;
|
||||
inp.max = 30;
|
||||
inp.step = 1;
|
||||
wrap.appendChild(inp);
|
||||
const span = document.createElement('span');
|
||||
span.className = 'range-value';
|
||||
span.setAttribute('data-for', fieldName);
|
||||
span.textContent = '0';
|
||||
wrap.appendChild(span);
|
||||
} else {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.name = fieldName;
|
||||
inp.id = fieldName;
|
||||
wrap.appendChild(inp);
|
||||
}
|
||||
} else {
|
||||
// render canonical codes -> labels; detect binary numeric yes/no
|
||||
const numericCodes = codes.map(c => Number(String(c))).filter(n => !Number.isNaN(n));
|
||||
const isBinary = (codes.length === 2) && (numericCodes.includes(0) || numericCodes.includes(1) || numericCodes.includes(2));
|
||||
if (isBinary) {
|
||||
const yesCode = codes.find(c => Number(c) === 1) || codes.find(c => Number(c) === 2) || codes[0];
|
||||
const noCode = codes.find(c => c !== yesCode) || null;
|
||||
const wrapper = document.createElement('div');
|
||||
const label = document.createElement('label');
|
||||
label.className = 'toggle-switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = fieldName;
|
||||
input.setAttribute('data-yes', yesCode.replace(/\.0$/, ''));
|
||||
input.setAttribute('data-no', noCode ? noCode.replace(/\.0$/, '') : '0');
|
||||
const slider = document.createElement('span');
|
||||
slider.className = 'slider';
|
||||
label.appendChild(input);
|
||||
label.appendChild(slider);
|
||||
wrapper.appendChild(label);
|
||||
const small = document.createElement('small');
|
||||
small.style.marginLeft = '10px';
|
||||
small.textContent = vals[yesCode] || 'Yes';
|
||||
wrapper.appendChild(small);
|
||||
wrap.appendChild(wrapper);
|
||||
} else {
|
||||
const likert = document.createElement('div');
|
||||
likert.className = 'likert';
|
||||
codes.slice().sort((a,b) => {
|
||||
const na = Number(a), nb = Number(b);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
||||
return String(a).localeCompare(String(b));
|
||||
}).forEach(code => {
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'radio';
|
||||
input.name = fieldName;
|
||||
input.value = String(code).replace(/\.0$/, '');
|
||||
label.appendChild(input);
|
||||
label.appendChild(document.createTextNode(' ' + (vals[code] || code)));
|
||||
likert.appendChild(label);
|
||||
});
|
||||
wrap.appendChild(likert);
|
||||
}
|
||||
}
|
||||
|
||||
row.appendChild(wrap);
|
||||
grid.appendChild(row);
|
||||
});
|
||||
|
||||
// init sliders to show values
|
||||
initSliders();
|
||||
|
||||
} catch (e) {
|
||||
console.warn('Failed to load variables:', e && e.message ? e.message : e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Render inputs for elements with data-var attribute using SERVER_VALUE_LABELS.
|
||||
// For each container with data-var="<varname>", we create controls based on the mapping:
|
||||
// - If the mapping looks binary (codes like 0/1) we render a toggle checkbox (data-yes/data-no)
|
||||
// - Otherwise we render radio inputs for each code->label pair (values are code without .0)
|
||||
function renderVariableControls() {
|
||||
console.log('[survey] renderVariableControls start, SERVER_VALUE_LABELS keys:', Object.keys(SERVER_VALUE_LABELS).length);
|
||||
const containers = Array.from(document.querySelectorAll('[data-var]'));
|
||||
containers.forEach(container => {
|
||||
const varName = container.getAttribute('data-var');
|
||||
if (!varName) return;
|
||||
const map = SERVER_VALUE_LABELS[varName];
|
||||
if (!map || typeof map !== 'object') return;
|
||||
// clear existing content
|
||||
container.innerHTML = '';
|
||||
const codes = Object.keys(map);
|
||||
// detect binary mapping (common pattern: 0.0/1.0 or 1.0/2.0 for yes/no)
|
||||
const numericCodes = codes.map(c => Number(String(c)) ).filter(n => !Number.isNaN(n));
|
||||
const isBinary = (codes.length === 2) && (numericCodes.includes(0) || numericCodes.includes(1) || numericCodes.includes(2));
|
||||
const fieldName = container.getAttribute('data-field') || varName;
|
||||
|
||||
if (isBinary) {
|
||||
// determine the 'yes' code (prefer 1, then 2)
|
||||
const yesCode = codes.find(c => Number(c) === 1) || codes.find(c => Number(c) === 2) || codes[0];
|
||||
const noCode = codes.find(c => c !== yesCode) || null;
|
||||
const yesLabel = map[yesCode] || 'Yes';
|
||||
const noLabel = noCode ? map[noCode] : 'No';
|
||||
// checkbox: when checked -> yes (1), unchecked -> no (0)
|
||||
const wrapper = document.createElement('div');
|
||||
const label = document.createElement('label');
|
||||
label.className = 'toggle-switch';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = fieldName;
|
||||
// encode yes/no as data attributes so collectForm interprets correctly
|
||||
input.setAttribute('data-yes', yesCode.replace(/\.0$/, ''));
|
||||
input.setAttribute('data-no', noCode ? noCode.replace(/\.0$/, '') : '0');
|
||||
const slider = document.createElement('span');
|
||||
slider.className = 'slider';
|
||||
label.appendChild(input);
|
||||
label.appendChild(slider);
|
||||
wrapper.appendChild(label);
|
||||
const small = document.createElement('small');
|
||||
small.style.marginLeft = '10px';
|
||||
small.textContent = yesLabel;
|
||||
wrapper.appendChild(small);
|
||||
container.appendChild(wrapper);
|
||||
} else {
|
||||
// render radios; sort codes numerically when possible for stable ordering
|
||||
const sorted = codes.slice().sort((a, b) => {
|
||||
const na = Number(a), nb = Number(b);
|
||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
sorted.forEach(code => {
|
||||
const labelText = map[code];
|
||||
const valueToken = String(code).replace(/\.0$/, '');
|
||||
const label = document.createElement('label');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'radio';
|
||||
input.name = fieldName;
|
||||
input.value = valueToken;
|
||||
label.appendChild(input);
|
||||
label.appendChild(document.createTextNode(' ' + labelText));
|
||||
container.appendChild(label);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: return the human-friendly label for a given field and token.
|
||||
// Lookup order:
|
||||
// 1) If SERVER_VALUE_LABELS has an entry for fieldName and a matching code -> return label.
|
||||
// We attempt exact match on token, token+'.0', and token with trailing '.0' removed.
|
||||
// 2) If VALUE_LABELS has an entry for token -> return that.
|
||||
// 3) Else return null to indicate no mapping.
|
||||
function labelFor(fieldName, token) {
|
||||
if (!token || token === 'nan') return null;
|
||||
// server-side per-variable lookup
|
||||
// determine canonical var id for this field so we can resolve per-variable value labels
|
||||
const varKey = FIELD_VARNAMES[fieldName] || fieldName;
|
||||
// Prefer VAR_VALUES (values shipped with /variables) as it contains the recoded labels
|
||||
if (VAR_VALUES && VAR_VALUES[varKey]) {
|
||||
const map = VAR_VALUES[varKey];
|
||||
// 1) If the token directly matches a code key, return the server label
|
||||
if (map.hasOwnProperty(token)) return map[token];
|
||||
// try token + '.0' and stripped
|
||||
if (map.hasOwnProperty(token + '.0')) return map[token + '.0'];
|
||||
const strippedToken = token.replace(/\.0$/, '');
|
||||
if (map.hasOwnProperty(strippedToken)) return map[strippedToken];
|
||||
// 2) If the token maps locally to a human label, see if the server map contains that label
|
||||
// (this lets us map semantic tokens like 'very_much' -> 'A great deal' if the server
|
||||
// contains that exact human label for the field)
|
||||
if (VALUE_LABELS.hasOwnProperty(token)) {
|
||||
const human = VALUE_LABELS[token];
|
||||
// try exact value match (case-sensitive); also try trimmed/case-insensitive
|
||||
for (const k of Object.keys(map)) {
|
||||
const serverLabel = map[k];
|
||||
if (!serverLabel) continue;
|
||||
if (serverLabel === human) return serverLabel;
|
||||
if (serverLabel.trim().toLowerCase() === human.trim().toLowerCase()) return serverLabel;
|
||||
}
|
||||
}
|
||||
// 3) additional numeric equivalence handled above
|
||||
// 4) numeric equivalence match
|
||||
try {
|
||||
const numToken = Number(token);
|
||||
if (!Number.isNaN(numToken)) {
|
||||
for (const k of Object.keys(map)) {
|
||||
if (!isNaN(Number(k)) && Number(k) === numToken) return map[k];
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// fallback to server-provided per-variable mapping if present
|
||||
if (SERVER_VALUE_LABELS && SERVER_VALUE_LABELS[varKey]) {
|
||||
const map = SERVER_VALUE_LABELS[varKey];
|
||||
if (map.hasOwnProperty(token)) return map[token];
|
||||
if (map.hasOwnProperty(token + '.0')) return map[token + '.0'];
|
||||
const stripped2 = token.replace(/\.0$/, '');
|
||||
if (map.hasOwnProperty(stripped2)) return map[stripped2];
|
||||
try {
|
||||
const numToken = Number(token);
|
||||
if (!Number.isNaN(numToken)) {
|
||||
for (const k of Object.keys(map)) {
|
||||
if (!isNaN(Number(k)) && Number(k) === numToken) return map[k];
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// fallback to global VALUE_LABELS exact match
|
||||
if (VALUE_LABELS.hasOwnProperty(token)) {
|
||||
console.log('[survey] labelFor fallback global VALUE_LABELS for', token, '->', VALUE_LABELS[token]);
|
||||
return VALUE_LABELS[token];
|
||||
}
|
||||
console.log('[survey] labelFor no mapping for', fieldName, token);
|
||||
return null;
|
||||
}
|
||||
|
||||
document.getElementById('predict').addEventListener('click', async () => {
|
||||
const d = collectForm();
|
||||
// Build a combined string of key:value for all form fields in a stable order
|
||||
// Missing values become 'nan' to mirror downstream expectations
|
||||
const parts = [];
|
||||
Object.keys(d).forEach(k => {
|
||||
let v = d[k];
|
||||
if (v === null || v === undefined || v === '') {
|
||||
v = 'nan';
|
||||
} else if (typeof v === 'object') {
|
||||
v = JSON.stringify(v);
|
||||
} else {
|
||||
v = String(v);
|
||||
}
|
||||
|
||||
// Attempt to map token to friendly label using per-field labels first,
|
||||
// then global fallback.
|
||||
if (typeof v === 'string') {
|
||||
const fieldLabelVal = labelFor(k, v);
|
||||
if (fieldLabelVal) {
|
||||
v = fieldLabelVal;
|
||||
}
|
||||
}
|
||||
|
||||
// Use server-provided internal_label (if available) as the key in the combined text
|
||||
// Fall back to the human-friendly FIELD_LABELS or the field name
|
||||
const internalKey = FIELD_INTERNALS[k] || FIELD_LABELS[k] || k;
|
||||
parts.push(`${internalKey}: ${v}`);
|
||||
});
|
||||
const combined = parts.join(' | ');
|
||||
|
||||
output.textContent = 'Sending...';
|
||||
console.log('[survey] Sending combined payload to /predict');
|
||||
try {
|
||||
// Build an internal-keyed mapping to send alongside the combined text
|
||||
const request_parsed_internal = {};
|
||||
Object.keys(d).forEach(k => {
|
||||
const internalKey = FIELD_INTERNALS[k] || FIELD_LABELS[k] || k;
|
||||
request_parsed_internal[internalKey] = d[k] === null || d[k] === undefined || d[k] === '' ? 'nan' : d[k];
|
||||
});
|
||||
|
||||
const res = await fetch('/predict', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: combined })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
output.textContent = `Error ${res.status}: ${text}`;
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
output.textContent = JSON.stringify(json, null, 2);
|
||||
} catch (err) {
|
||||
output.textContent = 'Network error: ' + (err && err.message ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Self-test: programmatically choose first option in each likert group, enable toggles and fill a couple fields,
|
||||
// then trigger the Predict flow so testers can quickly exercise the endpoint.
|
||||
function fillSelfTest() {
|
||||
// pick first radio of each group
|
||||
console.log('[survey] fillSelfTest starting');
|
||||
const radios = Array.from(document.querySelectorAll('input[type=radio]'));
|
||||
const grouped = {};
|
||||
radios.forEach(r => { if (!grouped[r.name]) grouped[r.name] = []; grouped[r.name].push(r); });
|
||||
Object.values(grouped).forEach(g => { if (g.length) g[0].checked = true; });
|
||||
|
||||
// enable all toggle checkboxes (rendered as .toggle-switch input)
|
||||
document.querySelectorAll('.toggle-switch input[type=checkbox]').forEach(ch => ch.checked = true);
|
||||
|
||||
// fill first text inputs
|
||||
const firstText = document.querySelector('input[type=text]'); if (firstText) firstText.value = 'Sample';
|
||||
|
||||
// set ranges to mid values
|
||||
document.querySelectorAll('input[type=range]').forEach(r => {
|
||||
const min = Number(r.min || 0);
|
||||
const max = Number(r.max || 10);
|
||||
r.value = Math.floor((min + max) / 2);
|
||||
r.dispatchEvent(new Event('input'));
|
||||
});
|
||||
|
||||
console.log('[survey] fillSelfTest done populating fields');
|
||||
// trigger predict
|
||||
const btn = document.getElementById('predict');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
|
||||
document.getElementById('selftest').addEventListener('click', fillSelfTest);
|
||||
|
||||
// Keep range-value spans in sync with range inputs
|
||||
function initSliders() {
|
||||
const ranges = Array.from(document.querySelectorAll('input[type=range]'));
|
||||
ranges.forEach(r => {
|
||||
const span = document.querySelector(`.range-value[data-for="${r.id}"]`);
|
||||
const update = () => {
|
||||
if (span) span.textContent = r.value;
|
||||
// set ARIA value
|
||||
r.setAttribute('aria-valuenow', r.value);
|
||||
};
|
||||
// initialize
|
||||
update();
|
||||
r.addEventListener('input', update);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initSliders);
|
||||
</script>
|
||||
|
||||
<p><small>If you need CSV export or different field typing, tell me and I'll add it.</small></p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -22,3 +22,33 @@ def get_ollama_embedding(text, model="nomic-embed-text"):
|
||||
response = requests.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
def parse_combined_text_to_dict(s: str) -> dict:
|
||||
"""Parse a string of the form 'key1: value1 | key2: value2' into a dict.
|
||||
|
||||
Rules:
|
||||
- Split on ' | ' to get key:value segments.
|
||||
- For each segment, split on the first ':' to separate key and value.
|
||||
- Strip whitespace. If a value is 'nan' (case-insensitive) or empty, use None.
|
||||
- Return a dict mapping keys to values or None.
|
||||
"""
|
||||
if not s:
|
||||
return {}
|
||||
result = {}
|
||||
parts = [p.strip() for p in s.split("|")]
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
# split on the first colon
|
||||
if ':' in part:
|
||||
k, v = part.split(':', 1)
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if v.lower() == 'nan' or v == '':
|
||||
result[k] = None
|
||||
else:
|
||||
result[k] = v
|
||||
else:
|
||||
# fallback: store whole segment under a numeric key
|
||||
result.setdefault('_extra', []).append(part)
|
||||
return result
|
||||
Reference in New Issue
Block a user