|
| 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