Skip to content

Commit 2bd7292

Browse files
committed
feat: Enhance demo UI and LLM integration with re-rank indicator and updated tooltips
1 parent 1928938 commit 2bd7292

File tree

13 files changed

+1195
-24
lines changed

13 files changed

+1195
-24
lines changed

demo/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# IAB Mapper Demo
2+
3+
This folder contains a small web demo (HTML/CSS/JS) and a FastAPI server that exposes the mapping endpoint.
4+
5+
## What it does
6+
- Upload a CSV or JSON of IAB 2.x labels (optional `code`).
7+
- Click “Map Now” to call `POST /api/map`.
8+
- View results with filters (unmatched only, confidence threshold, search).
9+
- Export results to CSV or JSON.
10+
- Banner shows a quick summary.
11+
- File picker shows the selected filename and row count after upload.
12+
13+
## Run locally
14+
1. Create and activate a virtual environment:
15+
```
16+
python3 -m venv .venv
17+
source .venv/bin/activate
18+
```
19+
2. Install the package and dev requirements:
20+
```
21+
pip install -e .
22+
pip install -r requirements-dev.txt
23+
```
24+
3. Start the server:
25+
```
26+
uvicorn scripts.web_server:app --port 8000 --reload
27+
```
28+
4. Open the demo:
29+
```
30+
http://localhost:8000/
31+
```
32+
33+
## API
34+
- `POST /api/map`
35+
- Request body:
36+
```json
37+
{
38+
"version_from": "2.x",
39+
"version_to": "3.0",
40+
"rows": [{"code":"1-4","label":"Sports"}],
41+
"options": {
42+
"confidence_min": 0.7
43+
}
44+
}
45+
```
46+
47+
## Samples
48+
- Small:
49+
- `sample_2x_codes.csv`
50+
- `sample_2x_codes.json`
51+
- Large (~100+ rows):
52+
- `sample_2x_codes_large.csv`
53+
- `sample_2x_codes_large.json`
54+
55+
CSV must include a header and a `label` column. Optional: `code`, `channel`, `type`, `format`, `language`, `source`, `environment`.

demo/app.js

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
const state = {
2+
rows: [],
3+
mapped: [],
4+
threshold: 0.7,
5+
unmatchedOnly: false,
6+
search: "",
7+
};
8+
9+
const els = {
10+
dz: document.getElementById("dropzone"),
11+
file: document.getElementById("fileInput"),
12+
mapBtn: document.getElementById("mapBtn"),
13+
conf: document.getElementById("confidence"),
14+
confVal: document.getElementById("confVal"),
15+
search: document.getElementById("search"),
16+
unmatched: document.getElementById("unmatchedOnly"),
17+
tbody: document.getElementById("resultsBody"),
18+
exportCsv: document.getElementById("exportCsv"),
19+
exportJson: document.getElementById("exportJson"),
20+
summary: document.getElementById("summary"),
21+
fileStatus: document.getElementById("fileStatus"),
22+
useEmbeddings: document.getElementById("useEmbeddings"),
23+
embCut: document.getElementById("embCut"),
24+
useLLM: document.getElementById("useLLM"),
25+
llmModel: document.getElementById("llmModel"),
26+
llmHost: document.getElementById("llmHost"),
27+
methods: document.getElementById("methods"),
28+
clearBtn: document.getElementById("clearBtn"),
29+
};
30+
31+
function fmtPct(n) {
32+
return (Math.round(n * 1000) / 10).toFixed(1) + "%";
33+
}
34+
35+
function readFileAsText(file) {
36+
return new Promise((resolve, reject) => {
37+
const reader = new FileReader();
38+
reader.onload = () => resolve(reader.result);
39+
reader.onerror = reject;
40+
reader.readAsText(file);
41+
});
42+
}
43+
44+
function parseCSV(text) {
45+
const lines = text.split(/\r?\n/).filter(Boolean);
46+
if (lines.length === 0) return [];
47+
const header = lines[0].split(",").map((s) => s.trim());
48+
const idx = (name) => header.indexOf(name);
49+
if (idx("label") === -1) throw new Error("CSV must include a 'label' column");
50+
const out = [];
51+
for (let i = 1; i < lines.length; i++) {
52+
const cols = lines[i].split(",");
53+
const rec = {
54+
code: idx("code") !== -1 ? (cols[idx("code")] || "").trim() : undefined,
55+
label: (cols[idx("label")] || "").trim(),
56+
channel: idx("channel") !== -1 ? (cols[idx("channel")] || "").trim() : undefined,
57+
type: idx("type") !== -1 ? (cols[idx("type")] || "").trim() : undefined,
58+
format: idx("format") !== -1 ? (cols[idx("format")] || "").trim() : undefined,
59+
language: idx("language") !== -1 ? (cols[idx("language")] || "").trim() : undefined,
60+
source: idx("source") !== -1 ? (cols[idx("source")] || "").trim() : undefined,
61+
environment: idx("environment") !== -1 ? (cols[idx("environment")] || "").trim() : undefined,
62+
};
63+
if (!rec.label) continue;
64+
out.push(rec);
65+
}
66+
return out;
67+
}
68+
69+
function validateRows(rows) {
70+
if (!Array.isArray(rows)) throw new Error("Input must be an array");
71+
for (const r of rows) {
72+
if (!r || typeof r !== "object") throw new Error("Row must be an object");
73+
if (!("label" in r) || !String(r.label || "").trim()) throw new Error("Each row must have a non-empty 'label'");
74+
}
75+
}
76+
77+
function setSummary(total, mapped, threshold, unmatched) {
78+
els.summary.textContent = `Mapped ${total} rows • ${fmtPct(mapped / Math.max(total, 1))} ≥ threshold • ${unmatched} unmatched → Request Audit`;
79+
}
80+
81+
function renderTable() {
82+
const t = state.threshold;
83+
const search = state.search.toLowerCase();
84+
const filtered = state.mapped.filter((r) => {
85+
const isUnmatched = !r.code_3x;
86+
if (state.unmatchedOnly && !isUnmatched) return false;
87+
if (r.confidence !== undefined && r.confidence < t && !isUnmatched) return false;
88+
if (!search) return true;
89+
const hay = `${r.code_2x || ""} ${r.label_2x || ""} ${r.code_3x || ""} ${r.label_3x || ""} ${r.method || ""}`.toLowerCase();
90+
return hay.includes(search);
91+
});
92+
93+
els.tbody.innerHTML = "";
94+
for (const r of filtered) {
95+
const tr = document.createElement("tr");
96+
const cells = [
97+
r.code_2x || "",
98+
r.label_2x || "",
99+
r.code_3x || "",
100+
r.label_3x || "",
101+
r.confidence !== undefined ? r.confidence.toFixed(3) : "",
102+
r.method || "",
103+
r.notes || "",
104+
r.llm_reranked ? "Yes" : "No", // Display "Yes" or "No" for LLM re-ranked
105+
];
106+
const methodDescriptions = {
107+
exact_code: "Direct code correspondence from curated seed/overrides",
108+
label_match: "Normalized label/path match",
109+
synonym: "Matched via alias dictionary",
110+
fuzzy: "String similarity retrieval",
111+
semantic: "Embedding similarity or LLM rerank",
112+
rapidfuzz: "Fuzzy match via RapidFuzz",
113+
bm25: "BM25 text retrieval",
114+
tfidf: "TF‑IDF cosine similarity",
115+
embed: "Semantic embedding similarity",
116+
override: "Forced mapping via overrides",
117+
};
118+
for (let i = 0; i < cells.length; i++) {
119+
const c = cells[i];
120+
const td = document.createElement("td");
121+
td.textContent = c;
122+
if (i === 5 && r.method) {
123+
td.title = methodDescriptions[r.method] || "";
124+
}
125+
tr.appendChild(td);
126+
}
127+
els.tbody.appendChild(tr);
128+
}
129+
130+
const total = state.mapped.length;
131+
const matched = state.mapped.filter((r) => r.code_3x && r.confidence >= t).length;
132+
const unmatched = state.mapped.filter((r) => !r.code_3x || r.confidence < t).length;
133+
setSummary(total, matched, t, unmatched);
134+
135+
const hasData = total > 0;
136+
els.exportCsv.disabled = !hasData;
137+
els.exportJson.disabled = !hasData;
138+
els.clearBtn.disabled = !hasData; // Enable/disable Clear button
139+
}
140+
141+
async function onFiles(files) {
142+
const file = files[0];
143+
if (!file) return;
144+
if (!/\.(csv|json)$/i.test(file.name)) {
145+
alert("Please upload a .csv or .json file");
146+
return;
147+
}
148+
try {
149+
const text = await readFileAsText(file);
150+
let rows;
151+
if (/\.csv$/i.test(file.name)) {
152+
rows = parseCSV(text);
153+
} else {
154+
rows = JSON.parse(text);
155+
}
156+
validateRows(rows);
157+
state.rows = rows;
158+
els.mapBtn.disabled = rows.length === 0;
159+
els.fileStatus.textContent = `${file.name}${rows.length} rows ready`;
160+
} catch (e) {
161+
console.error(e);
162+
alert(`Invalid file: ${e.message || e}`);
163+
els.fileStatus.textContent = "";
164+
}
165+
}
166+
167+
async function mapNow() {
168+
els.mapBtn.disabled = true;
169+
try {
170+
const body = {
171+
version_from: "2.x",
172+
version_to: "3.0",
173+
rows: state.rows,
174+
options: {
175+
confidence_min: state.threshold,
176+
fuzzy_method: "hybrid",
177+
methods: Array.from(els.methods?.querySelectorAll('input[name="methods"]:checked') || []).map(i => i.value),
178+
use_embeddings: !!els.useEmbeddings.checked,
179+
emb_model: "tfidf",
180+
emb_cut: els.embCut ? parseFloat(els.embCut.value || "0.80") : 0.8,
181+
use_llm: !!(els.useLLM && els.useLLM.checked),
182+
llm_model: els.llmModel ? els.llmModel.value : undefined,
183+
llm_host: els.llmHost ? els.llmHost.value : undefined,
184+
},
185+
};
186+
const res = await fetch(`/api/map`, {
187+
method: "POST",
188+
headers: { "Content-Type": "application/json" },
189+
body: JSON.stringify(body),
190+
});
191+
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
192+
const data = await res.json();
193+
state.mapped = data.rows || [];
194+
renderTable();
195+
} catch (e) {
196+
console.error(e);
197+
alert(`Map failed: ${e.message || e}`);
198+
} finally {
199+
els.mapBtn.disabled = false;
200+
els.clearBtn.disabled = state.rows.length === 0; // Enable Clear after mapping/error
201+
}
202+
}
203+
204+
function clearTable() {
205+
state.rows = [];
206+
state.mapped = [];
207+
state.search = "";
208+
els.fileInput.value = ""; // Clear selected file
209+
els.fileStatus.textContent = "";
210+
els.mapBtn.disabled = true;
211+
els.exportCsv.disabled = true;
212+
els.exportJson.disabled = true;
213+
els.clearBtn.disabled = true; // Disable Clear button
214+
els.tbody.innerHTML = "";
215+
setSummary(0, 0, state.threshold, 0); // Reset summary
216+
}
217+
218+
function toCSV(rows) {
219+
const cols = ["code_2x","label_2x","code_3x","label_3x","confidence","method","notes"];
220+
const lines = [cols.join(",")];
221+
for (const r of rows) {
222+
lines.push(cols.map((k) => {
223+
const v = r[k] == null ? "" : String(r[k]);
224+
if (v.includes(",") || v.includes("\n") || v.includes('"')) {
225+
return '"' + v.replaceAll('"', '""') + '"';
226+
}
227+
return v;
228+
}).join(","));
229+
}
230+
return lines.join("\n");
231+
}
232+
233+
function download(filename, content, type) {
234+
const blob = new Blob([content], { type });
235+
const url = URL.createObjectURL(blob);
236+
const a = document.createElement("a");
237+
a.href = url;
238+
a.download = filename;
239+
document.body.appendChild(a);
240+
a.click();
241+
a.remove();
242+
URL.revokeObjectURL(url);
243+
}
244+
245+
els.dz.addEventListener("click", () => els.file.click());
246+
els.dz.addEventListener("dragover", (e) => { e.preventDefault(); });
247+
els.dz.addEventListener("drop", (e) => { e.preventDefault(); onFiles(e.dataTransfer.files); });
248+
els.file.addEventListener("change", (e) => onFiles(e.target.files));
249+
250+
els.mapBtn.addEventListener("click", mapNow);
251+
252+
els.conf.addEventListener("input", (e) => {
253+
state.threshold = parseFloat(e.target.value);
254+
els.confVal.textContent = state.threshold.toFixed(2);
255+
renderTable();
256+
});
257+
258+
els.search.addEventListener("input", (e) => {
259+
state.search = e.target.value || "";
260+
renderTable();
261+
});
262+
263+
els.unmatched.addEventListener("change", (e) => {
264+
state.unmatchedOnly = !!e.target.checked;
265+
renderTable();
266+
});
267+
268+
els.clearBtn.addEventListener("click", clearTable); // Event listener for Clear button
269+
270+
els.exportCsv.addEventListener("click", () => {
271+
const csv = toCSV(state.mapped);
272+
download("mapped.csv", csv, "text/csv;charset=utf-8");
273+
});
274+
275+
els.exportJson.addEventListener("click", () => {
276+
download("mapped.json", JSON.stringify(state.mapped, null, 2), "application/json;charset=utf-8");
277+
});
278+
279+
// Tooltip titles for method explanations
280+
// No longer needed, using custom JS tooltips
281+
282+
const TOOLTIP_DELAY = 100; // ms
283+
284+
let tooltipTimeout; // to manage delayed display
285+
286+
function showTooltip(element, text) {
287+
const tooltip = document.createElement("div");
288+
tooltip.className = "custom-tooltip";
289+
tooltip.textContent = text;
290+
document.body.appendChild(tooltip);
291+
292+
const rect = element.getBoundingClientRect();
293+
tooltip.style.left = `${rect.left + rect.width / 2}px`;
294+
tooltip.style.top = `${rect.top - 10}px`; // 10px above the element
295+
tooltip.style.transform = `translate(-50%, -100%)`; // Center and position above
296+
}
297+
298+
function hideTooltip() {
299+
const existingTooltip = document.querySelector(".custom-tooltip");
300+
if (existingTooltip) {
301+
existingTooltip.remove();
302+
}
303+
}
304+
305+
document.addEventListener("mouseover", (event) => {
306+
const target = event.target.closest("[data-tooltip]");
307+
if (target) {
308+
clearTimeout(tooltipTimeout);
309+
tooltipTimeout = setTimeout(() => {
310+
showTooltip(target, target.getAttribute("data-tooltip"));
311+
}, TOOLTIP_DELAY);
312+
} else {
313+
clearTimeout(tooltipTimeout);
314+
hideTooltip();
315+
}
316+
});
317+
318+
document.addEventListener("mouseout", (event) => {
319+
const target = event.target.closest("[data-tooltip]");
320+
if (target) {
321+
clearTimeout(tooltipTimeout);
322+
hideTooltip();
323+
}
324+
});
325+
326+
// Prevent native title from showing alongside custom tooltip
327+
document.addEventListener("DOMContentLoaded", () => {
328+
document.querySelectorAll("[data-tooltip]").forEach(el => {
329+
if (el.hasAttribute("title")) {
330+
el.setAttribute("data-original-title", el.getAttribute("title"));
331+
el.removeAttribute("title");
332+
}
333+
});
334+
});
335+
336+

0 commit comments

Comments
 (0)