getting a bit further with the frontend and demoing the analysis to matti

This commit is contained in:
itsamejms
2025-09-19 14:57:03 +02:00
parent a44833d054
commit b2e412b3c5
8 changed files with 1354 additions and 233 deletions
+239
View File
@@ -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}")
# %%
+305
View File
File diff suppressed because one or more lines are too long
+15 -231
View File
@@ -1,236 +1,20 @@
# %% [markdown] import logging
# # Visualize Most Prepared Users import uvicorn
# This workflow loads user embeddings, generates a prompt embedding, computes similarity, and visualizes the most prepared users. import os
from api import app
# %% logging.basicConfig(level=logging.INFO)
import pandas as pd logger = logging.getLogger(__name__)
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) 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") if __name__ == "__main__":
run_api()
# %% [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}")
# %%
+3 -1
View File
@@ -7,4 +7,6 @@ seaborn
pyreadstat pyreadstat
plotly plotly
nbformat>=4.2.0 nbformat>=4.2.0
umap-learn[plot] umap-learn[plot]
fastapi
uvicorn
+4
View File
@@ -0,0 +1,4 @@
from pydantic import BaseModel
class PredictionRequest(BaseModel):
text: str
+757
View File
@@ -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>
+31 -1
View File
@@ -21,4 +21,34 @@ def get_ollama_embedding(text, model="nomic-embed-text"):
} }
response = requests.post(url, json=payload) response = requests.post(url, json=payload)
response.raise_for_status() response.raise_for_status()
return response.json()["embedding"] 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