Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TrueVisLies β Embedding Explorer</title> | |
| <link href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=" rel="icon" type="image/x-icon" /> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ββ Design tokens β dark (default) ββ */ | |
| :root { | |
| --bg: #0d0f14; | |
| --bg2: #13161d; | |
| --bg3: #1a1e28; | |
| --border: #252a38; | |
| --text: #c8cdd8; | |
| --text-dim: #5a6070; | |
| --text-hi: #eef0f4; | |
| --accent: #4f8ef7; | |
| --accent2: #e05c5c; | |
| --accent3: #52c994; | |
| --yellow: #f0c040; | |
| --legend-bg: rgba(13,15,20,0.88); | |
| --mono: 'IBM Plex Mono', monospace; | |
| --sans: 'IBM Plex Sans', sans-serif; | |
| --radius: 4px; | |
| } | |
| /* ββ Light theme overrides ββ */ | |
| body.light { | |
| --bg: #f5f6f8; | |
| --bg2: #eceef2; | |
| --bg3: #e2e5eb; | |
| --border: #cdd1da; | |
| --text: #1e2230; | |
| --text-dim: #7a8099; | |
| --text-hi: #0a0c12; | |
| --accent: #2563eb; | |
| --accent2: #c0392b; | |
| --accent3: #1a7a4a; | |
| --yellow: #b45309; | |
| --legend-bg: rgba(245,246,248,0.92); | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 13px; overflow: hidden; height: 100vh; display: flex; flex-direction: column; transition: background 0.2s, color 0.2s; } | |
| /* ββ Top bar ββ */ | |
| #topbar { display: flex; align-items: center; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0; } | |
| #topbar h1 { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent); letter-spacing: 0.05em; white-space: nowrap; } | |
| #topbar h1 span { color: var(--text-dim); } | |
| .sep { width: 1px; height: 18px; background: var(--border); } | |
| .ctrl-group { display: flex; align-items: center; gap: 6px; } | |
| .ctrl-label { font-family: var(--mono); font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; } | |
| select, input[type=text] { background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: 11px; padding: 4px 8px; border-radius: var(--radius); outline: none; cursor: pointer; } | |
| select:hover, input[type=text]:hover { border-color: var(--accent); } | |
| select:focus, input[type=text]:focus { border-color: var(--accent); } | |
| option { background: var(--bg2); } | |
| #status { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--text-dim); } | |
| #status.loading { color: var(--yellow); } | |
| #status.error { color: var(--accent2); } | |
| #status.ready { color: var(--accent3); } | |
| /* ββ Main layout ββ */ | |
| #main { display: flex; flex: 1; overflow: hidden; } | |
| /* ββ Left panel: scatterplot ββ */ | |
| #left-panel { display: flex; flex-direction: column; flex: 0 0 50vw; border-right: 1px solid var(--border); } | |
| #plot-header { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; background: var(--bg2); } | |
| #plot-header .title { font-family: var(--mono); font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; } | |
| #plot-controls { display: flex; gap: 6px; align-items: center; } | |
| #plot-wrap { flex: 1; position: relative; overflow: hidden; } | |
| #scatter-svg { width: 100%; height: 100%; } | |
| /* Brush */ | |
| .brush .selection { fill: var(--accent); fill-opacity: 0.1; stroke: var(--accent); stroke-width: 1px; } | |
| /* Dots */ | |
| .dot { transition: r 0.1s; cursor: pointer; } | |
| .dot:hover { r: 5; } | |
| .dot.selected { stroke: var(--text-hi); stroke-width: 1.5px; } | |
| .dot.faded { opacity: 0.08; } | |
| /* Legend */ | |
| #legend { position: absolute; bottom: 10px; left: 10px; background: var(--legend-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; font-family: var(--mono); font-size: 10px; } | |
| #legend .legend-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; } | |
| #legend .legend-row:last-child { margin-bottom: 0; } | |
| #legend .swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } | |
| /* Selection count badge */ | |
| #sel-badge { position: absolute; top: 10px; right: 10px; background: var(--accent); color: white; font-family: var(--mono); font-size: 10px; padding: 3px 7px; border-radius: 10px; display: none; } | |
| /* ββ Middle panel: items list ββ */ | |
| #mid-panel { display: flex; flex-direction: column; flex: 0 0 280px; border-right: 1px solid var(--border); } | |
| #mid-header { padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); display: flex; align-items: center; justify-content: space-between; } | |
| #mid-header .title { font-family: var(--mono); font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; } | |
| #mid-header .count { font-family: var(--mono); font-size: 10px; color: var(--accent); } | |
| #item-list { flex: 1; overflow-y: auto; } | |
| .item-row { padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; flex-direction: column; gap: 3px; transition: background 0.1s; } | |
| .item-row:hover { background: var(--bg3); } | |
| .item-row.active { background: var(--bg3); border-left: 2px solid var(--accent); padding-left: 10px; } | |
| .item-row .item-id { font-family: var(--mono); font-size: 10px; color: var(--accent); } | |
| .item-row .item-tag { display: inline-block; font-family: var(--mono); font-size: 9px; padding: 1px 5px; border-radius: 3px; } | |
| .item-row .item-tag.misleading { background: rgba(224,92,92,0.2); color: var(--accent2); } | |
| .item-row .item-tag.not-misleading { background: rgba(82,201,148,0.15); color: var(--accent3); } | |
| .item-row .item-preview { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .item-row .item-mean { font-family: var(--mono); font-size: 9px; color: var(--text-dim); } | |
| /* Analyze button */ | |
| #analyse-btn { margin: 10px 12px; padding: 7px 12px; background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: 10px; border-radius: var(--radius); cursor: pointer; text-align: center; letter-spacing: 0.05em; transition: border-color 0.15s, color 0.15s; } | |
| #analyse-btn:hover { border-color: var(--accent); color: var(--accent); } | |
| #analyse-btn:disabled { opacity: 0.3; cursor: default; } | |
| /* ββ Right panel: detail ββ */ | |
| #right-panel { display: flex; flex-direction: column; flex: 1; min-width: 0; } | |
| #detail-tabs { display: flex; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0; } | |
| .tab { padding: 8px 14px; font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); cursor: pointer; border-bottom: 2px solid transparent; transition: color 0.15s; white-space: nowrap; } | |
| .tab:hover { color: var(--text); } | |
| .tab.active { color: var(--accent); border-bottom-color: var(--accent); } | |
| #detail-content { flex: 1; overflow-y: auto; padding: 14px; } | |
| /* Detail: image + caption */ | |
| #pane-image { display: flex; gap: 14px; } | |
| #tweet-img { max-width: 280px; max-height: 240px; object-fit: contain; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg3); } | |
| #tweet-img.missing { display: flex; align-items: center; justify-content: center; width: 200px; height: 140px; color: var(--text-dim); font-family: var(--mono); font-size: 10px; } | |
| #caption-box { flex: 1; } | |
| #caption-text { font-size: 12px; line-height: 1.6; color: var(--text); background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; max-height: 200px; overflow-y: auto; } | |
| .detail-label { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); margin-bottom: 5px; } | |
| /* Detail: agreement heatmap */ | |
| #heatmap-wrap { overflow-x: auto; } | |
| #heatmap-svg text { font-family: var(--mono); font-size: 8px; fill: var(--text-dim); } | |
| /* Detail: model responses */ | |
| #responses-list { display: flex; flex-direction: column; gap: 10px; } | |
| .response-card { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; } | |
| .response-card .model-name { font-family: var(--mono); font-size: 10px; color: var(--accent); margin-bottom: 6px; } | |
| .response-card .verdict { display: inline-block; font-family: var(--mono); font-size: 9px; padding: 1px 6px; border-radius: 3px; margin-bottom: 6px; } | |
| .response-card .verdict.true { background: rgba(224,92,92,0.2); color: var(--accent2); } | |
| .response-card .verdict.false { background: rgba(82,201,148,0.15); color: var(--accent3); } | |
| .response-card .field-label { font-family: var(--mono); font-size: 9px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 6px; margin-bottom: 3px; } | |
| .response-card .field-text { font-size: 11px; line-height: 1.55; color: var(--text); } | |
| .response-card .scores-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; } | |
| .score-chip { font-family: var(--mono); font-size: 9px; padding: 1px 5px; background: var(--bg2); border: 1px solid var(--border); border-radius: 3px; } | |
| /* Detail: analysis output */ | |
| #analysis-output { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; font-size: 12px; line-height: 1.65; color: var(--text); white-space: pre-wrap; min-height: 120px; } | |
| #analysis-output.placeholder { color: var(--text-dim); font-style: italic; } | |
| #analysis-output.loading { color: var(--yellow); } | |
| #analysis-settings { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; } | |
| .settings-row { display: flex; gap: 8px; align-items: center; } | |
| .settings-row label { font-family: var(--mono); font-size: 10px; color: var(--text-dim); width: 90px; flex-shrink: 0; } | |
| .settings-row input[type=text], .settings-row select { flex: 1; } | |
| .settings-row input[type=password] { flex: 1; background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: 11px; padding: 4px 8px; border-radius: var(--radius); outline: none; } | |
| #run-analysis-btn { padding: 7px 14px; background: var(--accent); border: none; color: white; font-family: var(--mono); font-size: 11px; border-radius: var(--radius); cursor: pointer; letter-spacing: 0.04em; transition: opacity 0.15s; } | |
| #run-analysis-btn:hover { opacity: 0.85; } | |
| #run-analysis-btn:disabled { opacity: 0.4; cursor: default; } | |
| /* ID search input */ | |
| #id-search-input { flex: 1; } | |
| #id-search-btn:hover { opacity: 0.85; } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { width: 5px; height: 5px; } | |
| ::-webkit-scrollbar-track { background: var(--bg); } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } | |
| /* Empty state */ | |
| .empty-state { color: var(--text-dim); font-family: var(--mono); font-size: 11px; padding: 24px 12px; text-align: center; } | |
| /* Color-by model legend scroll */ | |
| #legend { max-height: 180px; overflow-y: auto; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ββ Top bar ββ --> | |
| <div id="topbar"> | |
| <h1>TrueVisLies <span>/ Embedding Explorer</span></h1> | |
| <div class="sep"></div> | |
| <div class="ctrl-group"> | |
| <span class="ctrl-label">Experiment</span> | |
| <select id="sel-experiment"> | |
| <option value="E1A">1A β Rhetoric / Free</option> | |
| <option value="E1B">1B β Rhetoric / Forced</option> | |
| <option value="E1C">1C β Rhetoric / Forced+</option> | |
| <option value="E2A">2A β Intent / Free</option> | |
| <option value="E2B">2B β Intent / Forced</option> | |
| <option value="E2C">2C β Intent / Forced+</option> | |
| </select> | |
| </div> | |
| <div class="ctrl-group"> | |
| <span class="ctrl-label">Key</span> | |
| <select id="sel-key"> | |
| <optgroup label="Analysis"> | |
| <option value="a___analysis">analysis</option> | |
| <option value="a___whymis">why misleading</option> | |
| <option value="a___analysis_whymis">analysis + why misleading</option> | |
| <option value="a___full_response">full response</option> | |
| <option value="a___behavior_signature">behavior signature</option> | |
| </optgroup> | |
| <optgroup label="Rhetoric"> | |
| <option value="r___all">rhetoric (all)</option> | |
| <option value="r___information_access_rhetoric">info access</option> | |
| <option value="r___mapping_rhetoric">mapping</option> | |
| <option value="r___linguistic_based_rhetoric">linguistic</option> | |
| <option value="r___provenance_rhetoric">provenance</option> | |
| <option value="r___procedural_rhetoric">procedural</option> | |
| </optgroup> | |
| <optgroup label="Intent"> | |
| <option value="i___all">intent (all)</option> | |
| <option value="i___aesthetic_driven_misrepresentation">aesthetic misrep.</option> | |
| <option value="i___bias_exploitation">bias exploitation</option> | |
| <option value="i___claim_supporting_manipulation">claim manipulation</option> | |
| <option value="i___context_distortion">context distortion</option> | |
| <option value="i___deliberate_reader_confusion">reader confusion</option> | |
| <option value="i___lack_of_visualization_literacy">viz literacy</option> | |
| <option value="i___selective_reporting">selective reporting</option> | |
| <option value="i___space_and_format_constraints">space constraints</option> | |
| <option value="i___unintentional_context_omission">uninten. omission</option> | |
| </optgroup> | |
| <optgroup label="Extracted Spans"> | |
| <option value="e___causal_reasoning">causal reasoning</option> | |
| <option value="e___evidence">evidence</option> | |
| <option value="e___interpretive_conclusion">conclusion</option> | |
| <option value="e___uncertainty">uncertainty</option> | |
| <option value="e___visual_focus">visual focus</option> | |
| <option value="e___caption_reasoning">caption reasoning</option> | |
| <option value="e___data_claim_gap">data claim gap</option> | |
| <option value="e___normative_baseline">normative baseline</option> | |
| <option value="e___intent_attribution">intent attribution</option> | |
| <option value="e___viewer_impact">viewer impact</option> | |
| </optgroup> | |
| </select> | |
| </div> | |
| <div class="ctrl-group"> | |
| <span class="ctrl-label">Color by</span> | |
| <select id="sel-color"> | |
| <option value="misleading">Misleading</option> | |
| <option value="mean_sim">Mean agreement</option> | |
| <option value="model">Model</option> | |
| </select> | |
| </div> | |
| <div class="ctrl-group"> | |
| <span class="ctrl-label">Filter</span> | |
| <select id="sel-filter"> | |
| <option value="all">All items</option> | |
| <option value="misleading">Misleading only</option> | |
| <option value="not_misleading">Not misleading only</option> | |
| </select> | |
| </div> | |
| <div class="ctrl-group"> | |
| <span class="ctrl-label">Plot mode</span> | |
| <select id="sel-plotmode"> | |
| <option value="B" title="One point per (item, model) β exact brush, composite key">B Β· per response</option> | |
| <option value="A" title="One point per (item, model) β brush selects all model-dots for matched items">A Β· by item (all models)</option> | |
| <option value="C" title="One point per image β x/y averaged across models">C Β· per image (avg)</option> | |
| </select> | |
| </div> | |
| <button id="btn-theme" style=" | |
| margin-left:4px; | |
| background:none; | |
| border:1px solid var(--border); | |
| color:var(--text-dim); | |
| font-family:var(--mono); | |
| font-size:9px; | |
| padding:3px 9px; | |
| border-radius:var(--radius); | |
| cursor:pointer; | |
| transition:border-color 0.15s,color 0.15s; | |
| white-space:nowrap; | |
| " title="Toggle light/dark theme">βΎ Dark</button> | |
| <span id="status" class="loading">Initializing DuckDBβ¦</span> | |
| </div> | |
| <!-- ββ Main ββ --> | |
| <div id="main"> | |
| <!-- Left: scatterplot --> | |
| <div id="left-panel"> | |
| <div id="plot-header"> | |
| <span class="title">UMAP Β· Agreement space</span> | |
| <div id="plot-controls"> | |
| <button id="btn-reset" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--mono);font-size:9px;padding:3px 7px;border-radius:var(--radius);cursor:pointer;">reset</button> | |
| <button id="btn-clear" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--mono);font-size:9px;padding:3px 7px;border-radius:var(--radius);cursor:pointer;">clear sel.</button> | |
| </div> | |
| </div> | |
| <div id="plot-wrap"> | |
| <svg id="scatter-svg"></svg> | |
| <div id="legend"></div> | |
| <div id="sel-badge">0 selected</div> | |
| </div> | |
| </div> | |
| <!-- Middle: item list --> | |
| <div id="mid-panel"> | |
| <div id="mid-header"> | |
| <span class="title">Items</span> | |
| <span id="item-count" class="count">β</span> | |
| </div> | |
| <!-- ID search / select --> | |
| <div id="id-search-box" style=" | |
| padding: 6px 10px; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| "> | |
| <div style="display:flex;gap:5px;align-items:center;"> | |
| <input | |
| id="id-search-input" | |
| type="text" | |
| placeholder="item id, or id1, id2, id3 β¦" | |
| style="flex:1;font-size:10px;padding:3px 6px;" | |
| /> | |
| <button id="id-search-btn" style=" | |
| background: var(--accent); | |
| border: none; | |
| color: #fff; | |
| font-family: var(--mono); | |
| font-size: 9px; | |
| padding: 4px 8px; | |
| border-radius: var(--radius); | |
| cursor: pointer; | |
| white-space: nowrap; | |
| ">Select</button> | |
| </div> | |
| <div id="id-search-msg" style=" | |
| font-family: var(--mono); | |
| font-size: 9px; | |
| color: var(--text-dim); | |
| min-height: 12px; | |
| "></div> | |
| </div> | |
| <button id="analyse-btn" disabled>β¦ Analyze selection with LLM</button> | |
| <div id="item-list"><div class="empty-state">Load data to see items</div></div> | |
| </div> | |
| <!-- Right: detail --> | |
| <div id="right-panel"> | |
| <div id="detail-tabs"> | |
| <div class="tab active" data-pane="image">Image & Caption</div> | |
| <div class="tab" data-pane="heatmap">Agreement & Scores</div> | |
| <div class="tab" data-pane="responses">Model Responses</div> | |
| <div class="tab" data-pane="analysis">LLM Analysis</div> | |
| </div> | |
| <div id="detail-content"> | |
| <div class="empty-state" style="margin-top:40px;">Select an item to inspect</div> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CONFIG β adjust paths to match your server layout | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // https://huggingface.co/datasets/<namespace>/<dataset_name>/resolve/main/<path_in_repo> | |
| const DATASET = 'twitter'; | |
| const CONFIG = { | |
| dataset: DATASET, | |
| datasetBase: `dataset`, | |
| resultsBase: `results`, | |
| umapBase: `results/umap`, | |
| imagesBase: `dataset/images`, | |
| dataBase: `dataset`, | |
| responsesBase: `results/responses`, | |
| }; | |
| // Resolve a relative path to an absolute URL based on the current page origin. | |
| // DuckDB-WASM requires absolute URLs for read_parquet() over HTTP. | |
| function absUrl(relativePath) { | |
| return new URL(relativePath, window.location.origin + '/').href; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DuckDB bootstrap | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm"; | |
| const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); | |
| let db, conn; | |
| async function initDuckDB() { | |
| const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); | |
| const worker_url = URL.createObjectURL( | |
| new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' }) | |
| ); | |
| const worker = new Worker(worker_url); | |
| const logger = new duckdb.ConsoleLogger(); | |
| db = new duckdb.AsyncDuckDB(logger, worker); | |
| await db.instantiate(bundle.mainModule, bundle.pthreadWorker); | |
| conn = await db.connect(); | |
| await conn.query(`SET enable_http_metadata_cache=true; SET enable_object_cache=true;`); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // State | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let state = { | |
| experiment: 'E1A', | |
| key: 'a___whymis', | |
| colorBy: 'misleading', | |
| filter: 'all', | |
| plotMode: 'A', // A=by-item(all dots), B=by-response(composite key), C=per-tweet(avg) | |
| umapData: [], // raw: [{item_id, model, x, y}] | |
| plotData: [], // derived from umapData according to plotMode | |
| cs1Data: [], // [{item_id, model_a, model_b, cosine_sim}] | |
| itemSummary: new Map(), // item_id β {mean_sim, min_sim, max_sim, is_misleading} | |
| models: [], | |
| // Mode B: selectedKeys is a Set of "item_id::model" composite strings | |
| // Mode A/C: selectedIds is a Set of item_id strings | |
| selectedIds: new Set(), // used in modes A and C | |
| selectedKeys: new Set(), // used in mode B | |
| activeItemId: null, | |
| activeTab: 'image', | |
| responseCache: new Map(), | |
| metadataCache: null, // loaded once from metadata.json | |
| }; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Status helper | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function setStatus(msg, cls='') { | |
| const el = document.getElementById('status'); | |
| el.textContent = msg; | |
| el.className = cls; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Data loading via DuckDB | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function getUmapPath(key, experiment) { | |
| const base = CONFIG.resultsBase; | |
| return absUrl(`${base}/umap/${key}/${experiment}.parquet`); | |
| } | |
| function getCs1Path(key, experiment) { | |
| const base = CONFIG.resultsBase | |
| return absUrl(`${base}/similarity/model_agreement/${key}/${experiment}.parquet`); | |
| } | |
| async function loadData() { | |
| const { experiment, key } = state; | |
| setStatus('Loadingβ¦', 'loading'); | |
| const umapPath = getUmapPath(key, experiment); | |
| const cs1Path = getCs1Path(key, experiment); | |
| try { | |
| // Load UMAP | |
| const umapRes = await conn.query(`SELECT * FROM read_parquet('${umapPath}')`); | |
| state.umapData = umapRes.toArray().map(r => ({ | |
| item_id: String(r.item_id ?? r.image_id), | |
| model: r.model ? String(r.model) : null, | |
| x: Number(r.x), | |
| y: Number(r.y), | |
| })).filter(r => !isNaN(r.x) && !isNaN(r.y)); | |
| // Load CS1 | |
| const cs1Res = await conn.query(`SELECT * FROM read_parquet('${cs1Path}')`); | |
| state.cs1Data = cs1Res.toArray().map(r => ({ | |
| item_id: String(r.item_id ?? r.image_id), | |
| model_a: String(r.model_a), | |
| model_b: String(r.model_b), | |
| cosine_sim: Number(r.cosine_sim), | |
| })); | |
| // Extract unique models | |
| const mset = new Set(); | |
| state.cs1Data.forEach(r => { mset.add(r.model_a); mset.add(r.model_b); }); | |
| state.models = Array.from(mset).sort(); | |
| // Build per-item summary from CS1 | |
| buildItemSummary(); | |
| clearSelection(); | |
| state.responseCache.clear(); | |
| buildPlotData(); | |
| setStatus(`${state.plotData.length} pts Β· ${state.models.length} models Β· mode ${state.plotMode}`, 'ready'); | |
| renderPlot(); | |
| renderItemList(Array.from(state.itemSummary.keys())); | |
| // Load ground truth in the background β re-render once done | |
| loadGroundTruth().then(() => { | |
| renderPlot(); | |
| renderItemList(Array.from( | |
| selectedItemIds().size > 0 ? selectedItemIds() : state.itemSummary.keys() | |
| )); | |
| }); | |
| } catch(e) { | |
| setStatus(`Error: ${e.message}`, 'error'); | |
| console.error(e); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Ground truth loading | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Uses DuckDB to bulk-read all item JSON files in one query via glob, | |
| // extracting item_id (from filename) and the "misleading" boolean field. | |
| // Falls back to per-file fetch if glob is not supported by the server. | |
| async function loadGroundTruth() { | |
| const allIds = Array.from(state.itemSummary.keys()); | |
| if (allIds.length === 0) return; | |
| let loaded = 0; | |
| // Strategy 1: read index.csv via DuckDB | |
| try { | |
| const csvUrl = absUrl(`${CONFIG.dataBase}/index.csv`); | |
| const res = await conn.query(`SELECT image_id, is_misleading FROM read_csv_auto('${csvUrl}')`); | |
| res.toArray().forEach(r => { | |
| const itemId = String(r.image_id); | |
| const isMis = Boolean(Number(r.is_misleading)); | |
| if (state.itemSummary.has(itemId)) { | |
| state.itemSummary.get(itemId).is_misleading = isMis; | |
| loaded++; | |
| } | |
| }); | |
| if (loaded > 0) { | |
| setStatus(`${state.plotData.length} pts Β· ${state.models.length} models Β· ${loaded} labels loaded`, 'ready'); | |
| return; | |
| } | |
| } catch(e) { | |
| console.warn('DuckDB CSV failed, falling back to fetch:', e.message); | |
| } | |
| // Strategy 2: fetch index.csv directly | |
| try { | |
| const resp = await fetch(absUrl(`${CONFIG.dataBase}/index.csv`)); | |
| if (resp.ok) { | |
| const text = await resp.text(); | |
| const lines = text.trim().split('\n'); | |
| const header = lines[0].split(','); | |
| const idCol = header.indexOf('image_id'); | |
| const misCol = header.indexOf('is_misleading'); | |
| for (let i = 1; i < lines.length; i++) { | |
| const cols = lines[i].split(','); | |
| const itemId = cols[idCol]; | |
| const isMis = cols[misCol] === '1' || cols[misCol]?.toLowerCase() === 'true'; | |
| if (state.itemSummary.has(itemId)) { | |
| state.itemSummary.get(itemId).is_misleading = isMis; | |
| loaded++; | |
| } | |
| } | |
| } | |
| } catch(e) { | |
| console.warn('CSV fetch also failed:', e.message); | |
| } | |
| setStatus(`${state.plotData.length} pts Β· ${state.models.length} models Β· ${loaded} labels loaded`, 'ready'); | |
| } | |
| function buildItemSummary() { | |
| const agg = new Map(); | |
| state.cs1Data.forEach(r => { | |
| if (!agg.has(r.item_id)) agg.set(r.item_id, []); | |
| agg.get(r.item_id).push(r.cosine_sim); | |
| }); | |
| state.itemSummary.clear(); | |
| agg.forEach((sims, id) => { | |
| let sum = 0, lo = Infinity, hi = -Infinity; | |
| for (let i = 0; i < sims.length; i++) { | |
| const v = sims[i]; | |
| sum += v; | |
| if (v < lo) lo = v; | |
| if (v > hi) hi = v; | |
| } | |
| state.itemSummary.set(id, { | |
| mean_sim: sum / sims.length, | |
| min_sim: lo, | |
| max_sim: hi, | |
| is_misleading: null, | |
| }); | |
| }); | |
| } | |
| // ββ Derive plotData from umapData according to the current plotMode ββββββββββ | |
| // Mode A: raw rows (item_id, model, x, y) β one dot per (item Γ model) | |
| // selection key = item_id β all model-dots for a tweet highlight together | |
| // Mode B: raw rows (item_id, model, x, y) β one dot per (item Γ model) | |
| // selection key = "item_id::model" β only the exact brushed dots highlight | |
| // Mode C: one dot per item_id β x/y averaged across models | |
| function buildPlotData() { | |
| const { umapData, plotMode } = state; | |
| if (plotMode === 'A' || plotMode === 'B') { | |
| state.plotData = umapData; | |
| } else { | |
| // Mode C: average x/y per item_id | |
| const agg = new Map(); | |
| umapData.forEach(d => { | |
| if (!agg.has(d.item_id)) agg.set(d.item_id, { xs: [], ys: [], model: d.model }); | |
| agg.get(d.item_id).xs.push(d.x); | |
| agg.get(d.item_id).ys.push(d.y); | |
| }); | |
| state.plotData = Array.from(agg.entries()).map(([item_id, v]) => ({ | |
| item_id, | |
| model: null, | |
| x: v.xs.reduce((a,b)=>a+b,0)/v.xs.length, | |
| y: v.ys.reduce((a,b)=>a+b,0)/v.ys.length, | |
| })); | |
| } | |
| } | |
| // Helpers for mode-aware selection key | |
| function dotKey(d) { | |
| return state.plotMode === 'B' ? `${d.item_id}::${d.model}` : d.item_id; | |
| } | |
| function isSelected(d) { | |
| return state.plotMode === 'B' | |
| ? state.selectedKeys.has(dotKey(d)) | |
| : state.selectedIds.has(d.item_id); | |
| } | |
| function clearSelection() { | |
| state.selectedIds.clear(); | |
| state.selectedKeys.clear(); | |
| } | |
| function addToSelection(d) { | |
| if (state.plotMode === 'B') state.selectedKeys.add(dotKey(d)); | |
| else state.selectedIds.add(d.item_id); | |
| } | |
| // Return the set of unique item_ids currently selected (for item list / cluster analysis) | |
| function selectedItemIds() { | |
| if (state.plotMode === 'B') { | |
| return new Set(Array.from(state.selectedKeys).map(k => k.split('::')[0])); | |
| } | |
| return state.selectedIds; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Load metadata.json once and cache it | |
| async function ensureMetadata() { | |
| if (state.metadataCache) return state.metadataCache; | |
| try { | |
| const r = await fetch(absUrl(`${CONFIG.dataBase}/metadata.json`)); | |
| if (!r.ok) return {}; | |
| state.metadataCache = await r.json(); | |
| return state.metadataCache; | |
| } catch { state.metadataCache = {}; return {}; } | |
| } | |
| // Load item caption from cached metadata | |
| async function loadCaption(itemId) { | |
| const meta = await ensureMetadata(); | |
| const entry = meta[itemId]; | |
| if (!entry) return null; | |
| return entry.caption || entry.text || null; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Load model responses for an item from experiment parquet | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadResponses(itemId) { | |
| if (state.responseCache.has(itemId)) return state.responseCache.get(itemId); | |
| const { experiment } = state; | |
| const result = {}; | |
| try { | |
| const path = absUrl(`${CONFIG.responsesBase}/${experiment}.parquet`); | |
| const res = await conn.query( | |
| `SELECT * FROM read_parquet('${path}') WHERE CAST(image_id AS VARCHAR) = '${itemId}'` | |
| ); | |
| const schema = res.schema.fields.map(f => f.name); | |
| const rows = res.toArray(); | |
| for (const r of rows) { | |
| const model = String(r.model); | |
| // Convert Arrow proxy to plain object (handles pipe chars in column names) | |
| const obj = {}; | |
| for (const col of schema) obj[col] = r[col]; | |
| // Reconstruct nested taxonomy objects from flat pipe-delimited columns | |
| // e.g. "r|mapping_rhetoric|score" β visualization_rhetoric.mapping_rhetoric.misleading_contribution_score | |
| // e.g. "i|bias_exploitation|why" β author_intents.bias_exploitation.why | |
| const rhetoric = {}, intents = {}; | |
| for (const col of schema) { | |
| const parts = col.split('|'); | |
| if (parts.length !== 3) continue; | |
| const [prefix, cat, field] = parts; | |
| const target = prefix === 'r' ? rhetoric : prefix === 'i' ? intents : null; | |
| if (!target) continue; | |
| if (!target[cat]) target[cat] = {}; | |
| if (field === 'score') target[cat].misleading_contribution_score = obj[col]; | |
| else if (field === 'why') target[cat].why_contribute_to_misleading = obj[col]; | |
| else target[cat][field] = obj[col]; | |
| } | |
| if (Object.keys(rhetoric).length) obj.visualization_rhetoric = rhetoric; | |
| if (Object.keys(intents).length) obj.author_intents = intents; | |
| result[model] = obj; | |
| } | |
| } catch(e) { | |
| console.warn('Failed to load responses:', e.message); | |
| } | |
| state.responseCache.set(itemId, result); | |
| return result; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Scatterplot | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const MODEL_COLORS = d3.schemeTableau10.concat(d3.schemeDark2); | |
| function getModelColor(model) { | |
| const idx = state.models.indexOf(model); | |
| return MODEL_COLORS[idx % MODEL_COLORS.length] || '#888'; | |
| } | |
| function getDotColor(item_id, model) { | |
| const { colorBy } = state; | |
| if (colorBy === 'misleading') { | |
| const s = state.itemSummary.get(item_id); | |
| if (!s || s.is_misleading === null) return '#5a6070'; | |
| return s.is_misleading ? '#e05c5c' : '#52c994'; | |
| } | |
| if (colorBy === 'mean_sim') { | |
| const s = state.itemSummary.get(item_id); | |
| if (!s) return '#5a6070'; | |
| return d3.interpolateRdYlGn(s.mean_sim * 0.5 + 0.5); | |
| } | |
| if (colorBy === 'model' && model) { | |
| return getModelColor(model); | |
| } | |
| return '#4f8ef7'; | |
| } | |
| // Plot mode subtitle shown below the plot header title | |
| const PLOT_MODE_DESC = { | |
| A: 'Mode A Β· one dot per (image Γ model) Β· brush selects all model-dots for matched images', | |
| B: 'Mode B Β· one dot per (image Γ model) Β· brush selects only the exact brushed dots', | |
| C: 'Mode C Β· one dot per image Β· position = average across models', | |
| }; | |
| function renderPlot() { | |
| const svg = d3.select('#scatter-svg'); | |
| const wrap = document.getElementById('plot-wrap'); | |
| const W = wrap.clientWidth; | |
| const H = wrap.clientHeight; | |
| svg.attr('viewBox', `0 0 ${W} ${H}`).attr('width', W).attr('height', H); | |
| svg.selectAll('*').remove(); | |
| // Update subtitle | |
| document.querySelector('#plot-header .title').textContent = | |
| `UMAP Β· ${PLOT_MODE_DESC[state.plotMode]}`; | |
| buildPlotData(); | |
| let data = state.plotData; | |
| // Filter by misleadingness | |
| if (state.filter === 'misleading') { | |
| data = data.filter(d => state.itemSummary.get(d.item_id)?.is_misleading === true); | |
| } else if (state.filter === 'not_misleading') { | |
| data = data.filter(d => state.itemSummary.get(d.item_id)?.is_misleading === false); | |
| } | |
| if (data.length === 0) return; | |
| const pad = 30; | |
| const xS = d3.scaleLinear().domain(d3.extent(data, d => d.x)).range([pad, W-pad]); | |
| const yS = d3.scaleLinear().domain(d3.extent(data, d => d.y)).range([H-pad, pad]); | |
| state._xS = xS; state._yS = yS; | |
| const hasSel = state.plotMode === 'B' | |
| ? state.selectedKeys.size > 0 | |
| : state.selectedIds.size > 0; | |
| const g = svg.append('g'); | |
| g.selectAll('circle') | |
| .data(data) | |
| .join('circle') | |
| .attr('class', d => { | |
| let cls = 'dot'; | |
| if (hasSel && !isSelected(d)) cls += ' faded'; | |
| if (isSelected(d)) cls += ' selected'; | |
| return cls; | |
| }) | |
| .attr('cx', d => xS(d.x)) | |
| .attr('cy', d => yS(d.y)) | |
| .attr('r', state.plotMode === 'C' ? 4 : 3) | |
| .attr('fill', d => getDotColor(d.item_id, d.model)) | |
| .attr('fill-opacity', 0.75) | |
| .on('click', (evt, d) => { | |
| evt.stopPropagation(); | |
| selectItem(d.item_id); | |
| }); | |
| // Brush β mode-aware selection logic | |
| const brush = d3.brush() | |
| .extent([[0,0],[W,H]]) | |
| .on('end', (evt) => { | |
| if (!evt.selection) { clearSelection(); updateBadge(); rerenderDots(); return; } | |
| const [[x0,y0],[x1,y1]] = evt.selection; | |
| clearSelection(); | |
| data.forEach(d => { | |
| const cx = xS(d.x), cy = yS(d.y); | |
| if (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1) addToSelection(d); | |
| }); | |
| updateBadge(); | |
| rerenderDots(); | |
| const selIds = selectedItemIds(); | |
| renderItemList(Array.from(selIds.size > 0 ? selIds : state.itemSummary.keys())); | |
| }); | |
| svg.append('g').attr('class','brush').call(brush); | |
| renderLegend(data); | |
| } | |
| function rerenderDots() { | |
| const hasSel = state.plotMode === 'B' | |
| ? state.selectedKeys.size > 0 | |
| : state.selectedIds.size > 0; | |
| d3.selectAll('.dot') | |
| .attr('class', d => { | |
| let cls = 'dot'; | |
| if (hasSel && !isSelected(d)) cls += ' faded'; | |
| if (isSelected(d)) cls += ' selected'; | |
| return cls; | |
| }); | |
| } | |
| function updateBadge() { | |
| const badge = document.getElementById('sel-badge'); | |
| const selIds = selectedItemIds(); | |
| const n = selIds.size; | |
| if (n > 0) { | |
| const modeNote = state.plotMode === 'B' | |
| ? ` (${state.selectedKeys.size} responses)` | |
| : ''; | |
| badge.textContent = `${n} tweets${modeNote}`; | |
| badge.style.display = 'block'; | |
| } else { | |
| badge.style.display = 'none'; | |
| } | |
| document.getElementById('analyse-btn').disabled = n === 0; | |
| } | |
| function renderLegend(data) { | |
| const leg = document.getElementById('legend'); | |
| leg.innerHTML = ''; | |
| const { colorBy } = state; | |
| if (colorBy === 'misleading') { | |
| [['#e05c5c','Misleading'],['#52c994','Not misleading'],['#5a6070','Unknown']] | |
| .forEach(([c,l]) => { | |
| const row = document.createElement('div'); | |
| row.className = 'legend-row'; | |
| row.innerHTML = `<div class="swatch" style="background:${c}"></div><span>${l}</span>`; | |
| leg.appendChild(row); | |
| }); | |
| } else if (colorBy === 'model') { | |
| state.models.forEach(m => { | |
| const row = document.createElement('div'); | |
| row.className = 'legend-row'; | |
| row.innerHTML = `<div class="swatch" style="background:${getModelColor(m)}"></div><span>${m.substring(0,22)}</span>`; | |
| leg.appendChild(row); | |
| }); | |
| } else if (colorBy === 'mean_sim') { | |
| leg.innerHTML = `<div class="legend-row"><span style="font-size:9px;color:var(--text-dim)">Low agreement β High agreement</span></div> | |
| <div style="height:8px;width:120px;background:linear-gradient(to right,#d73027,#fee08b,#1a9850);border-radius:2px;margin-top:4px;"></div>`; | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Item list | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderItemList(ids) { | |
| // ids may be a Set or Array | |
| ids = Array.isArray(ids) ? ids : Array.from(ids); | |
| const list = document.getElementById('item-list'); | |
| document.getElementById('item-count').textContent = `${ids.length}`; | |
| if (ids.length === 0) { | |
| list.innerHTML = '<div class="empty-state">No items match</div>'; | |
| return; | |
| } | |
| // Sort by mean agreement ascending (most disagreement first β more interesting) | |
| const sorted = [...ids].sort((a,b) => { | |
| const sa = state.itemSummary.get(a)?.mean_sim ?? 1; | |
| const sb = state.itemSummary.get(b)?.mean_sim ?? 1; | |
| return sa - sb; | |
| }); | |
| list.innerHTML = ''; | |
| sorted.slice(0, 300).forEach(id => { | |
| const s = state.itemSummary.get(id); | |
| const row = document.createElement('div'); | |
| row.className = 'item-row' + (id === state.activeItemId ? ' active' : ''); | |
| row.dataset.id = id; | |
| const tagCls = s?.is_misleading === true ? 'misleading' : s?.is_misleading === false ? 'not-misleading' : ''; | |
| const tagLbl = s?.is_misleading === true ? 'misleading' : s?.is_misleading === false ? 'not misleading' : '?'; | |
| const mean = s ? (s.mean_sim * 100).toFixed(0) + '% agreement' : ''; | |
| row.innerHTML = ` | |
| <div style="display:flex;align-items:center;justify-content:space-between;"> | |
| <span class="item-id">${id}</span> | |
| <span class="item-tag ${tagCls}">${tagLbl}</span> | |
| </div> | |
| <span class="item-mean">${mean}</span>`; | |
| row.addEventListener('click', () => selectItem(id)); | |
| list.appendChild(row); | |
| }); | |
| if (sorted.length > 300) { | |
| const more = document.createElement('div'); | |
| more.className = 'empty-state'; | |
| more.textContent = `+ ${sorted.length - 300} more`; | |
| list.appendChild(more); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Select item | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function selectItem(itemId) { | |
| state.activeItemId = itemId; | |
| // Update item list highlight | |
| document.querySelectorAll('.item-row').forEach(r => { | |
| r.classList.toggle('active', r.dataset.id === itemId); | |
| }); | |
| renderDetailPane(); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Detail pane routing | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function renderDetailPane() { | |
| const { activeTab, activeItemId } = state; | |
| if (!activeItemId) return; | |
| if (activeTab === 'image') renderImagePane(activeItemId); | |
| if (activeTab === 'heatmap') await renderHeatmapPane(activeItemId); | |
| if (activeTab === 'responses') renderResponsesPane(activeItemId); | |
| if (activeTab === 'analysis') renderAnalysisPane(); | |
| } | |
| // ββ Image & Caption ββββββββββββββββββββββββββββββββββ | |
| async function renderImagePane(itemId) { | |
| const dc = document.getElementById('detail-content'); | |
| dc.innerHTML = ` | |
| <div id="pane-image"> | |
| <div> | |
| <div class="detail-label">Visualization</div> | |
| <img id="tweet-img" src="${CONFIG.imagesBase}/${itemId}.png" | |
| onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" | |
| alt="tweet visualization"/> | |
| <div id="img-missing" class="tweet-img missing" style="display:none;width:200px;height:140px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);align-items:center;justify-content:center;color:var(--text-dim);font-family:var(--mono);font-size:10px;">no image</div> | |
| </div> | |
| <div id="caption-box"> | |
| <div class="detail-label">Caption</div> | |
| <div id="caption-text" class="caption-text">Loadingβ¦</div> | |
| <div style="margin-top:10px;" class="detail-label">Item ID</div> | |
| <div style="font-family:var(--mono);font-size:11px;color:var(--accent);margin-top:4px;">${itemId}</div> | |
| <div style="margin-top:8px;" class="detail-label">Agreement stats</div> | |
| <div id="agreement-stats" style="font-family:var(--mono);font-size:11px;margin-top:4px;"></div> | |
| </div> | |
| </div>`; | |
| const s = state.itemSummary.get(itemId); | |
| if (s) { | |
| document.getElementById('agreement-stats').innerHTML = | |
| `mean: <span style="color:var(--accent)">${(s.mean_sim*100).toFixed(1)}%</span> | |
| min: <span style="color:var(--accent2)">${(s.min_sim*100).toFixed(1)}%</span> | |
| max: <span style="color:var(--accent3)">${(s.max_sim*100).toFixed(1)}%</span>`; | |
| } | |
| const caption = await loadCaption(itemId); | |
| const captionEl = document.getElementById('caption-text'); | |
| if (captionEl) captionEl.textContent = caption || '(no caption available)'; | |
| } | |
| // ββ Agreement Heatmap tab β two sections βββββββββββββ | |
| async function renderHeatmapPane(itemId) { | |
| const dc = document.getElementById('detail-content'); | |
| dc.innerHTML = '<div class="empty-state">Loadingβ¦</div>'; | |
| const cs1rows = state.cs1Data.filter(r => r.item_id === itemId); | |
| const responses = await loadResponses(itemId); | |
| // Determine taxonomy from experiment name | |
| const isRhetoric = state.experiment.startsWith('E1'); | |
| const taxKey = isRhetoric ? 'visualization_rhetoric' : 'author_intents'; | |
| // Short display names for categories | |
| const CAT_LABELS = { | |
| information_access_rhetoric: 'Info Access', | |
| provenance_rhetoric: 'Provenance', | |
| mapping_rhetoric: 'Mapping', | |
| linguistic_based_rhetoric: 'Linguistic', | |
| procedural_rhetoric: 'Procedural', | |
| aesthetic_driven_misrepresentation:'Aesthetic', | |
| bias_exploitation: 'Bias Exploit.', | |
| claim_supporting_manipulation: 'Claim Manip.', | |
| context_distortion: 'Context Dist.', | |
| deliberate_reader_confusion: 'Reader Conf.', | |
| lack_of_visualization_literacy: 'Viz Literacy', | |
| selective_reporting: 'Selective Rep.', | |
| space_and_format_constraints: 'Space Constr.', | |
| unintentional_context_omission: 'Uninten. Omit.', | |
| }; | |
| dc.innerHTML = ` | |
| <div style="display:flex;flex-direction:column;gap:24px;"> | |
| <!-- Section 1: Embedding cosine similarity heatmap --> | |
| <div> | |
| <div class="detail-label" style="margin-bottom:6px;"> | |
| Embedding similarity (cosine) β semantic agreement on "${state.key}" responses | |
| </div> | |
| <div class="detail-sublabel" style="font-family:var(--mono);font-size:9px;color:var(--text-dim);margin-bottom:8px;"> | |
| Measures how semantically similar two models' text responses were. High similarity β same score. | |
| </div> | |
| <div id="heatmap-embed" style="overflow-x:auto;"></div> | |
| </div> | |
| <!-- Section 2: Score heatmap (models Γ categories) --> | |
| <div> | |
| <div class="detail-label" style="margin-bottom:6px;"> | |
| Contribution scores β models Γ ${isRhetoric ? 'rhetoric' : 'intent'} categories | |
| </div> | |
| <div class="detail-sublabel" style="font-family:var(--mono);font-size:9px;color:var(--text-dim);margin-bottom:8px;"> | |
| Score scale: 0 = none Β· 1β6 = minimal β very strong Β· grey = unknown (β1) | |
| </div> | |
| <div id="heatmap-scores" style="overflow-x:auto;"></div> | |
| </div> | |
| <!-- Section 3: Per-category agreement (std dev bar + mean dot strip) --> | |
| <div> | |
| <div class="detail-label" style="margin-bottom:6px;"> | |
| Score distribution per category β dots = individual models Β· bar = std deviation | |
| </div> | |
| <div class="detail-sublabel" style="font-family:var(--mono);font-size:9px;color:var(--text-dim);margin-bottom:8px;"> | |
| Tall bar = high disagreement between models Β· low bar = consensus Β· dot position = score value | |
| </div> | |
| <div id="heatmap-dist" style="overflow-x:auto;"></div> | |
| </div> | |
| </div>`; | |
| const dimColor = getComputedStyle(document.documentElement) | |
| .getPropertyValue('--text-dim').trim(); | |
| const bgColor = getComputedStyle(document.documentElement) | |
| .getPropertyValue('--bg3').trim(); | |
| // ββ Section 1: Cosine similarity heatmap ββββββββββββββββββ | |
| if (cs1rows.length > 0) { | |
| const models = state.models; | |
| const n = models.length; | |
| const mat = Array.from({length:n}, () => Array(n).fill(null)); | |
| cs1rows.forEach(r => { | |
| const i = models.indexOf(r.model_a); | |
| const j = models.indexOf(r.model_b); | |
| if (i >= 0 && j >= 0) { mat[i][j] = r.cosine_sim; mat[j][i] = r.cosine_sim; } | |
| }); | |
| for(let k=0;k<n;k++) mat[k][k] = 1.0; | |
| const cell = 20, lpad = 110, tpad = 100; | |
| const W = lpad + n*cell + 10, H = tpad + n*cell + 10; | |
| const colorEmb = d3.scaleSequential(d3.interpolateRdYlGn).domain([-0.2, 1]); | |
| const svgE = d3.select('#heatmap-embed').append('svg') | |
| .attr('width', W).attr('height', H) | |
| .style('font-family','var(--mono)'); | |
| const gE = svgE.append('g').attr('transform', `translate(${lpad},${tpad})`); | |
| models.forEach((ma, i) => { | |
| models.forEach((mb, j) => { | |
| const v = mat[i][j]; | |
| gE.append('rect') | |
| .attr('x', j*cell).attr('y', i*cell) | |
| .attr('width', cell-1).attr('height', cell-1) | |
| .attr('fill', v !== null ? colorEmb(v) : bgColor) | |
| .append('title').text(`${ma} Γ ${mb}: ${v !== null ? v.toFixed(3) : 'N/A'}`); | |
| }); | |
| }); | |
| models.forEach((m,i) => { | |
| gE.append('text').attr('x',-4).attr('y',i*cell+cell/2+3) | |
| .attr('text-anchor','end').attr('font-size',8).attr('fill',dimColor) | |
| .text(m.length>16 ? m.substring(0,15)+'β¦' : m); | |
| }); | |
| models.forEach((m,j) => { | |
| gE.append('text').attr('x',j*cell+cell/2).attr('y',-4) | |
| .attr('text-anchor','start').attr('font-size',8).attr('fill',dimColor) | |
| .attr('transform',`rotate(-45,${j*cell+cell/2},-4)`) | |
| .text(m.length>16 ? m.substring(0,15)+'β¦' : m); | |
| }); | |
| } else { | |
| document.getElementById('heatmap-embed').innerHTML = | |
| '<div class="empty-state">No CS1 data for this key/experiment</div>'; | |
| } | |
| // ββ Extract scores from responses ββββββββββββββββββββββββββ | |
| const modelKeys = Object.keys(responses).sort(); | |
| if (modelKeys.length === 0) { | |
| ['heatmap-scores','heatmap-dist'].forEach(id => { | |
| document.getElementById(id).innerHTML = | |
| '<div class="empty-state">No response data β open Model Responses tab first to cache</div>'; | |
| }); | |
| return; | |
| } | |
| // Collect categories (union of all models' keys) | |
| const catSet = new Set(); | |
| modelKeys.forEach(m => { | |
| const resp = responses[m].response || responses[m]; | |
| const tax = resp[taxKey]; | |
| if (tax) Object.keys(tax).forEach(c => catSet.add(c)); | |
| }); | |
| const categories = Array.from(catSet).sort(); | |
| if (categories.length === 0) { | |
| ['heatmap-scores','heatmap-dist'].forEach(id => { | |
| document.getElementById(id).innerHTML = | |
| `<div class="empty-state">No ${taxKey} scores in responses</div>`; | |
| }); | |
| return; | |
| } | |
| // score matrix: modelKeys Γ categories | |
| const scoreMatrix = modelKeys.map(m => { | |
| const resp = responses[m].response || responses[m]; | |
| const tax = resp[taxKey] || {}; | |
| return categories.map(cat => { | |
| const entry = tax[cat]; | |
| return entry !== undefined ? Number(entry.misleading_contribution_score ?? 0) : 0; | |
| }); | |
| }); | |
| // ββ Section 2: Score heatmap (models Γ categories) βββββββββ | |
| { | |
| const cellW = 52, cellH = 18; | |
| const lpad = 110, tpad = 100; | |
| const nc = categories.length, nm = modelKeys.length; | |
| const W = lpad + nc*cellW + 10, H = tpad + nm*cellH + 10; | |
| // Color: grey for -1, sequential blue 0β6 for 0β6 | |
| const colorScore = (v) => { | |
| const n = Number(v); | |
| if (n === -1) return '#555566'; | |
| return d3.interpolateBlues(n / 6); | |
| }; | |
| const svgS = d3.select('#heatmap-scores').append('svg') | |
| .attr('width', W).attr('height', H) | |
| .style('font-family','var(--mono)'); | |
| const gS = svgS.append('g').attr('transform',`translate(${lpad},${tpad})`); | |
| modelKeys.forEach((m, i) => { | |
| categories.forEach((cat, j) => { | |
| const v = scoreMatrix[i][j]; | |
| const fill = colorScore(v); | |
| gS.append('rect') | |
| .attr('x', j*cellW).attr('y', i*cellH) | |
| .attr('width', cellW-1).attr('height', cellH-1) | |
| .attr('fill', fill) | |
| .append('title').text(`${m} Β· ${cat}: ${v === -1 ? 'unknown' : v}`); | |
| // Score label inside cell | |
| if (v !== 0) { | |
| gS.append('text') | |
| .attr('x', j*cellW + cellW/2).attr('y', i*cellH + cellH/2 + 4) | |
| .attr('text-anchor','middle').attr('font-size',9) | |
| .attr('fill', v >= 3 ? '#fff' : dimColor) | |
| .attr('pointer-events','none') | |
| .text(v === -1 ? '?' : String(v)); | |
| } | |
| }); | |
| }); | |
| // Model row labels | |
| modelKeys.forEach((m,i) => { | |
| gS.append('text').attr('x',-4).attr('y',i*cellH+cellH/2+3) | |
| .attr('text-anchor','end').attr('font-size',8).attr('fill',dimColor) | |
| .text(m.length>16 ? m.substring(0,15)+'β¦' : m); | |
| }); | |
| // Category col labels (rotated) | |
| categories.forEach((cat,j) => { | |
| const label = CAT_LABELS[cat] || cat.replace(/_/g,' '); | |
| gS.append('text') | |
| .attr('x', j*cellW + cellW/2).attr('y', -4) | |
| .attr('text-anchor','start').attr('font-size',8).attr('fill',dimColor) | |
| .attr('transform',`rotate(-40,${j*cellW+cellW/2},-4)`) | |
| .text(label); | |
| }); | |
| // Color scale legend | |
| const legX = 0, legY = nm*cellH + 16; | |
| [0,1,2,3,4,5,6].forEach((v,k) => { | |
| gS.append('rect') | |
| .attr('x', legX+k*22).attr('y', legY) | |
| .attr('width',21).attr('height',8) | |
| .attr('fill', colorScore(v)); | |
| gS.append('text') | |
| .attr('x', legX+k*22+10).attr('y', legY+18) | |
| .attr('text-anchor','middle').attr('font-size',8).attr('fill',dimColor) | |
| .text(v); | |
| }); | |
| gS.append('rect').attr('x', legX+7*22).attr('y', legY) | |
| .attr('width',21).attr('height',8).attr('fill','#555566'); | |
| gS.append('text').attr('x', legX+7*22+10).attr('y', legY+18) | |
| .attr('text-anchor','middle').attr('font-size',8).attr('fill',dimColor) | |
| .text('?'); | |
| } | |
| // ββ Section 3: Dot strip + std-dev bar per category ββββββββ | |
| { | |
| const barH = 60; // height of each category panel | |
| const dotArea = 40; // vertical range for dots (scores 0β6) | |
| const barArea = 12; // height of the std-dev bar | |
| const gap = 8; | |
| const catW = 72; | |
| const lpad = 10, tpad = 14; | |
| const nc = categories.length; | |
| const W = lpad + nc*catW + 20; | |
| const H = tpad + barH + 40; | |
| const yScore = d3.scaleLinear().domain([0,6]).range([dotArea, 0]); | |
| const svgD = d3.select('#heatmap-dist').append('svg') | |
| .attr('width', W).attr('height', H) | |
| .style('font-family','var(--mono)'); | |
| const gD = svgD.append('g').attr('transform',`translate(${lpad},${tpad})`); | |
| categories.forEach((cat, j) => { | |
| const scores = modelKeys.map((_,i) => scoreMatrix[i][j]) | |
| .filter(v => Number(v) >= 0).map(Number); // exclude -1 (unknown), coerce to number | |
| const mean = scores.length ? scores.reduce((a,b)=>a+b,0)/scores.length : 0; | |
| const std = scores.length > 1 | |
| ? Math.sqrt(scores.map(v=>(v-mean)**2).reduce((a,b)=>a+b,0)/scores.length) | |
| : 0; | |
| const cx = j*catW + catW/2; | |
| const g = gD.append('g').attr('transform',`translate(${cx},0)`); | |
| // Background bar showing score range 0β6 | |
| g.append('rect') | |
| .attr('x',-catW/2+4).attr('y',0) | |
| .attr('width',catW-8).attr('height',dotArea) | |
| .attr('fill','none').attr('stroke',dimColor).attr('stroke-opacity',0.12) | |
| .attr('rx',2); | |
| // Std-dev bar (centered on mean y) | |
| if (scores.length > 0) { | |
| const meanY = yScore(mean); | |
| const stdPx = std * (dotArea/6); | |
| g.append('rect') | |
| .attr('x',-8).attr('y', Math.max(0, meanY - stdPx)) | |
| .attr('width',16) | |
| .attr('height', Math.min(dotArea, stdPx*2 + 1)) | |
| .attr('fill','var(--accent)').attr('opacity',0.18); | |
| // Mean line | |
| g.append('line') | |
| .attr('x1',-14).attr('x2',14).attr('y1',meanY).attr('y2',meanY) | |
| .attr('stroke','var(--accent)').attr('stroke-width',1.5); | |
| // Individual model dots (jittered x) | |
| const nonZero = scores.filter(v => v > 0); | |
| const all0 = nonZero.length === 0; | |
| scores.forEach((v, k) => { | |
| const jitter = (k % 5 - 2) * 2.5; | |
| g.append('circle') | |
| .attr('cx', jitter).attr('cy', yScore(v)) | |
| .attr('r', 3) | |
| .attr('fill', v === 0 ? dimColor : 'var(--accent2)') | |
| .attr('opacity', v === 0 ? 0.25 : 0.8) | |
| .append('title').text(`${modelKeys[modelKeys.findIndex((_,i2)=>scoreMatrix[i2][j]===v)] ?? '?'}: ${v}`); | |
| }); | |
| } | |
| // Category label (below) | |
| const label = CAT_LABELS[cat] || cat.replace(/_/g,' '); | |
| const words = label.split(' '); | |
| words.forEach((w, wi) => { | |
| g.append('text') | |
| .attr('x',0).attr('y', dotArea + gap + 10 + wi*10) | |
| .attr('text-anchor','middle').attr('font-size',8).attr('fill',dimColor) | |
| .text(w); | |
| }); | |
| // Std value | |
| g.append('text') | |
| .attr('x',0).attr('y', dotArea + gap + 10 + words.length*10 + 2) | |
| .attr('text-anchor','middle').attr('font-size',7) | |
| .attr('fill','var(--accent)').attr('opacity',0.7) | |
| .text(`Ο=${std.toFixed(1)}`); | |
| }); | |
| // Y axis labels (0, 3, 6) | |
| [0,3,6].forEach(v => { | |
| gD.append('text') | |
| .attr('x', nc*catW + 6).attr('y', yScore(v) + 3) | |
| .attr('font-size',7).attr('fill',dimColor).text(v); | |
| }); | |
| gD.append('text') | |
| .attr('x', nc*catW + 6).attr('y', -2) | |
| .attr('font-size',7).attr('fill',dimColor).text('score'); | |
| } | |
| } | |
| // ββ Model Responses ββββββββββββββββββββββββββββββββββ | |
| async function renderResponsesPane(itemId) { | |
| const dc = document.getElementById('detail-content'); | |
| dc.innerHTML = `<div class="empty-state">Loading responsesβ¦</div>`; | |
| const responses = await loadResponses(itemId); | |
| const keys = Object.keys(responses); | |
| if (keys.length === 0) { | |
| dc.innerHTML = '<div class="empty-state">No responses found for this item</div>'; | |
| return; | |
| } | |
| dc.innerHTML = '<div id="responses-list"></div>'; | |
| const list = document.getElementById('responses-list'); | |
| keys.sort().forEach(model => { | |
| const r = responses[model]; | |
| const resp = r.response || r; | |
| const isMis = resp.is_misleading; | |
| const card = document.createElement('div'); | |
| card.className = 'response-card'; | |
| // Scores row β extract from pipe-delimited columns (e.g. i|bias_exploitation|score) | |
| let scoresHtml = ''; | |
| const scoreEntries = []; | |
| for (const [col, val] of Object.entries(resp)) { | |
| if (col.endsWith('|score') && Number(val) > 0) { | |
| const parts = col.split('|'); | |
| const cat = parts.slice(1, -1).join(' ').replace(/_/g, ' '); | |
| scoreEntries.push({ cat, score: Number(val) }); | |
| } | |
| } | |
| if (scoreEntries.length > 0) { | |
| scoresHtml = `<div class="field-label">Scores</div><div class="scores-row">`; | |
| scoreEntries.forEach(({ cat, score }) => { | |
| scoresHtml += `<span class="score-chip" title="${cat}">${cat}: ${score}</span>`; | |
| }); | |
| scoresHtml += `</div>`; | |
| } | |
| card.innerHTML = ` | |
| <div class="model-name">${model}</div> | |
| <span class="verdict ${isMis}">${isMis ? 'β misleading' : 'β not misleading'}</span> | |
| ${resp.analysis ? `<div class="field-label">Analysis</div><div class="field-text">${resp.analysis.substring(0,400)}${resp.analysis.length>400?'β¦':''}</div>` : ''} | |
| ${resp.why_misleading ? `<div class="field-label">Why misleading</div><div class="field-text">${resp.why_misleading.substring(0,300)}${resp.why_misleading.length>300?'β¦':''}</div>` : ''} | |
| ${scoresHtml}`; | |
| list.appendChild(card); | |
| // Update is_misleading in summary from actual response data | |
| // Use majority vote across models | |
| }); | |
| // Update is_misleading via majority vote | |
| let trueCount = 0, falseCount = 0; | |
| keys.forEach(m => { | |
| const v = (responses[m].response || responses[m]).is_misleading; | |
| if (v === true) trueCount++; | |
| if (v === false) falseCount++; | |
| }); | |
| if (state.itemSummary.has(itemId)) { | |
| state.itemSummary.get(itemId).is_misleading = trueCount >= falseCount; | |
| } | |
| } | |
| // ββ LLM Analysis βββββββββββββββββββββββββββββββββββββ | |
| let llmConfig = { | |
| provider: 'anthropic', | |
| apiKey: '', | |
| model: 'claude-sonnet-4-20250514', | |
| endpoint: '', | |
| }; | |
| function renderAnalysisPane() { | |
| const dc = document.getElementById('detail-content'); | |
| dc.innerHTML = ` | |
| <div id="analysis-settings"> | |
| <div class="settings-row"> | |
| <label>Provider</label> | |
| <select id="llm-provider"> | |
| <option value="local" ${llmConfig.provider==='local'?'selected':''}>Local (OpenAI-compatible)</option> | |
| <option value="openai" ${llmConfig.provider==='openai'?'selected':''}>OpenAI</option> | |
| <option value="anthropic" ${llmConfig.provider==='anthropic'?'selected':''}>Anthropic</option> | |
| </select> | |
| </div> | |
| <div class="settings-row"> | |
| <label>Model</label> | |
| <input type="text" id="llm-model" value="${llmConfig.model}" placeholder="model name"/> | |
| </div> | |
| <div class="settings-row"> | |
| <label>API Key</label> | |
| <input type="password" id="llm-apikey" value="${llmConfig.apiKey}" placeholder="sk-β¦ or leave empty for local"/> | |
| </div> | |
| <div class="settings-row" id="endpoint-row" style="${llmConfig.provider==='local'?'':'display:none'}"> | |
| <label>Endpoint</label> | |
| <input type="text" id="llm-endpoint" value="${llmConfig.endpoint}" placeholder="http://localhost:11434/v1"/> | |
| </div> | |
| </div> | |
| <div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;"> | |
| <button id="run-analysis-btn">βΆ Run analysis</button> | |
| <span style="font-family:var(--mono);font-size:10px;color:var(--text-dim);">on selected item Β· ${state.activeItemId || 'β'}</span> | |
| </div> | |
| <div class="detail-label" style="margin-bottom:6px;">Output</div> | |
| <div id="analysis-output" class="placeholder">Configure provider above and click Run to analyse the selected item across all models.</div>`; | |
| document.getElementById('llm-provider').addEventListener('change', e => { | |
| llmConfig.provider = e.target.value; | |
| document.getElementById('endpoint-row').style.display = e.target.value === 'local' ? '' : 'none'; | |
| // Set sensible default model | |
| if (e.target.value === 'anthropic') document.getElementById('llm-model').value = 'claude-sonnet-4-20250514'; | |
| if (e.target.value === 'openai') document.getElementById('llm-model').value = 'gpt-5.4'; | |
| if (e.target.value === 'local') document.getElementById('llm-model').value = 'openai/gpt-oss-120b'; | |
| }); | |
| document.getElementById('run-analysis-btn').addEventListener('click', runLLMAnalysis); | |
| } | |
| // Helper: build a structured taxonomy block for one response | |
| function buildTaxonomyBlock(resp, taxKey) { | |
| const tax = resp[taxKey]; | |
| if (!tax) return ' (no taxonomy data)'; | |
| return Object.entries(tax).map(([cat, val]) => { | |
| const score = Number(val?.misleading_contribution_score ?? 0); | |
| const why = (val?.why_contribute_to_misleading || '').trim(); | |
| const scoreStr = score === -1 ? 'unknown' : score === 0 ? 'none (0)' : `${score}/6`; | |
| const whyStr = why ? `\n Explanation: "${why.substring(0, 180)}${why.length > 180 ? 'β¦' : ''}"` : ''; | |
| return ` β’ ${cat.replace(/_/g,' ')}: ${scoreStr}${whyStr}`; | |
| }).join('\n'); | |
| } | |
| // Helper: compute per-category score statistics across all models | |
| function buildScoreStats(responses, taxKey) { | |
| const allCats = new Set(); | |
| Object.values(responses).forEach(r => { | |
| const resp = r.response || r; | |
| const tax = resp[taxKey]; | |
| if (tax) Object.keys(tax).forEach(c => allCats.add(c)); | |
| }); | |
| const modelList = Object.keys(responses).sort(); | |
| const lines = []; | |
| allCats.forEach(cat => { | |
| const scores = modelList.map(m => { | |
| const resp = responses[m].response || responses[m]; | |
| const val = resp[taxKey]?.[cat]; | |
| return Number(val?.misleading_contribution_score ?? 0); | |
| }).filter(v => v >= 0); // exclude -1 | |
| if (scores.length === 0) return; | |
| const mean = scores.reduce((a,b)=>a+b,0) / scores.length; | |
| const std = Math.sqrt(scores.map(v=>(v-mean)**2).reduce((a,b)=>a+b,0)/scores.length); | |
| const nonZero = scores.filter(v => v > 0); | |
| const activated = nonZero.length; | |
| const maxScore = Math.max(...scores); | |
| const minScore = Math.min(...scores); | |
| // Find models with highest and lowest scores | |
| const scored = modelList.map((m,i) => ({m, v: scores[i]})).filter(x => x.v >= 0); | |
| scored.sort((a,b) => b.v - a.v); | |
| const topModels = scored.slice(0,3).map(x=>`${x.m}(${x.v})`).join(', '); | |
| const botModels = scored.slice(-2).reverse().map(x=>`${x.m}(${x.v})`).join(', '); | |
| lines.push( | |
| ` ${cat.replace(/_/g,' ')}:\n` + | |
| ` Activated by ${activated}/${modelList.length} models Β· mean=${mean.toFixed(2)} Β· Ο=${std.toFixed(2)} Β· range=[${minScore}β${maxScore}]\n` + | |
| ` Highest: ${topModels}\n` + | |
| ` Lowest: ${botModels}` | |
| ); | |
| }); | |
| return lines.join('\n\n') || ' (no score data)'; | |
| } | |
| async function runLLMAnalysis() { | |
| const itemId = state.activeItemId; | |
| if (!itemId) return; | |
| llmConfig.provider = document.getElementById('llm-provider').value; | |
| llmConfig.model = document.getElementById('llm-model').value; | |
| llmConfig.apiKey = document.getElementById('llm-apikey').value; | |
| llmConfig.endpoint = document.getElementById('llm-endpoint')?.value || ''; | |
| const output = document.getElementById('analysis-output'); | |
| output.className = 'loading'; | |
| output.textContent = 'Loading responses and preparing promptβ¦'; | |
| const responses = await loadResponses(itemId); | |
| const caption = await loadCaption(itemId); | |
| const s = state.itemSummary.get(itemId); | |
| const isRhetoric = state.experiment.startsWith('E1'); | |
| const taxKey = isRhetoric ? 'visualization_rhetoric' : 'author_intents'; | |
| const taxLabel = isRhetoric ? 'Visualization Rhetoric' : 'Author Intent'; | |
| const modelList = Object.keys(responses).sort(); | |
| const nModels = modelList.length; | |
| // Verdict summary | |
| let trueCount = 0, falseCount = 0, unknownCount = 0; | |
| modelList.forEach(m => { | |
| const v = (responses[m].response || responses[m]).is_misleading; | |
| if (v === true) trueCount++; else if (v === false) falseCount++; else unknownCount++; | |
| }); | |
| const verdictSummary = `${trueCount} misleading / ${falseCount} not misleading / ${unknownCount} unknown`; | |
| // Score statistics block | |
| const scoreStats = buildScoreStats(responses, taxKey); | |
| // Per-model detailed taxonomy block (all models, full data) | |
| const perModelBlocks = modelList.map(m => { | |
| const resp = responses[m].response || responses[m]; | |
| const verdict = resp.is_misleading ? 'β misleading' : 'β not misleading'; | |
| const analysis = (resp.analysis || '').substring(0, 350); | |
| const whyMis = (resp.why_misleading || '').substring(0, 250); | |
| const taxBlock = buildTaxonomyBlock(resp, taxKey); | |
| return [ | |
| `Model: ${m}`, | |
| ` Verdict: ${verdict}`, | |
| analysis ? ` Analysis: "${analysis}${resp.analysis?.length>350?'β¦':''}"` : '', | |
| whyMis ? ` Why misleading: "${whyMis}${resp.why_misleading?.length>250?'β¦':''}"` : '', | |
| ` ${taxLabel} scores:`, | |
| taxBlock, | |
| ].filter(Boolean).join('\n'); | |
| }).join('\n\n' + 'β'.repeat(60) + '\n\n'); | |
| output.textContent = 'Building promptβ¦'; | |
| const prompt = `You are an expert in data visualization research, media studies, and large language model evaluation. You are performing a deep analytical study of how 16 state-of-the-art multimodal LLMs reason about a COVID-19 social media visualization that may or may not be misleading. | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ITEM CONTEXT | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Item ID: ${itemId} | |
| Experiment: ${state.experiment} (${isRhetoric ? 'Rhetoric task' : 'Authorial intent task'}) | |
| Caption: ${caption || '(unavailable)'} | |
| Ground truth misleading: ${s?.is_misleading === true ? 'YES' : s?.is_misleading === false ? 'NO' : 'unknown'} | |
| Embedding cosine agreement (${state.key}): ${s ? (s.mean_sim*100).toFixed(1)+'%' : 'N/A'} | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| VERDICT SUMMARY ACROSS ${nModels} MODELS | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ${verdictSummary} | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ${taxLabel.toUpperCase()} SCORE STATISTICS (per category) | |
| Scores: 0=none, 1=minimal, 2=limited, 3=moderate, 4=considerable, 5=strong, 6=very strong | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ${scoreStats} | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| FULL PER-MODEL RESPONSES WITH TAXONOMY SCORES | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ${perModelBlocks} | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| YOUR ANALYSIS TASK | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Please provide a thorough, structured analysis covering: | |
| 1. VERDICT AGREEMENT | |
| - Do models agree on whether this visualization is misleading? | |
| - If there is disagreement, which models dissent and what distinguishes their reasoning? | |
| - Is the majority verdict consistent with the ground truth? | |
| 2. ${taxLabel.toUpperCase()} PATTERNS | |
| - Which ${isRhetoric ? 'rhetoric' : 'intent'} categories were most frequently activated (high mean score)? | |
| - Which categories show the highest inter-model disagreement (high Ο)? | |
| - Which categories show strong consensus (low Ο, consistent scores)? | |
| - Are there categories where only a minority of models activated them β and do those models share a common reasoning pattern? | |
| 3. DIVERGENCE DEEP DIVE | |
| - Identify the most striking disagreements: which specific model pairs diverge most, on which categories, and why? | |
| - For the most contested category, compare the verbatim explanations of agreeing vs disagreeing models. What do they notice differently about the visualization? | |
| - Is there a pattern in which types of models (by size, architecture family, or training) tend to agree with each other? | |
| 4. CONVERGENCE PATTERNS | |
| - What elements of the visualization did nearly all models mention or focus on? | |
| - Is there a "common narrative" that most models share, even if they differ on scores? | |
| - Which specific visual or textual features appear in multiple models' explanations? | |
| 5. QUALITY AND DEPTH ASSESSMENT | |
| - Which models provided the most specific, grounded explanations (citing concrete visual elements)? | |
| - Which models were vague or generic? | |
| - Are there cases where a model's score and its textual explanation are internally inconsistent? | |
| 6. RESEARCH IMPLICATIONS | |
| - What does the pattern of agreement/disagreement on this item suggest about LLM capabilities in detecting this type of misleading visualization? | |
| - If you were to select this item as an illustrative example for a research paper, what point would it best illustrate? | |
| Be analytical, specific, and cite model names and scores when making claims. Structure your response clearly with the numbered sections above.`; | |
| output.textContent = 'Calling APIβ¦'; | |
| try { | |
| let text; | |
| if (llmConfig.provider === 'anthropic') { | |
| text = await callAnthropic(prompt); | |
| } else { | |
| text = await callOpenAI(prompt); | |
| } | |
| output.className = ''; | |
| output.textContent = text; | |
| } catch(e) { | |
| output.className = 'placeholder'; | |
| output.textContent = `Error: ${e.message}`; | |
| } | |
| } | |
| async function callAnthropic(prompt) { | |
| const res = await fetch('https://api.anthropic.com/v1/messages', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'x-api-key': llmConfig.apiKey, | |
| 'anthropic-version': '2023-06-01', | |
| 'anthropic-dangerous-direct-browser-access': 'true', | |
| }, | |
| body: JSON.stringify({ | |
| model: llmConfig.model, | |
| max_tokens: 64000, | |
| messages: [{ role: 'user', content: prompt }], | |
| }), | |
| }); | |
| if (!res.ok) throw new Error(`Anthropic API error ${res.status}: ${await res.text()}`); | |
| const j = await res.json(); | |
| return j.content?.[0]?.text || '(empty response)'; | |
| } | |
| async function callOpenAI(prompt) { | |
| const baseUrl = llmConfig.provider === 'local' | |
| ? (llmConfig.endpoint || 'http://localhost:11434/v1') | |
| : 'https://api.openai.com/v1'; | |
| const res = await fetch(`${baseUrl}/chat/completions`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${llmConfig.apiKey || 'none'}`, | |
| }, | |
| body: JSON.stringify({ | |
| model: llmConfig.model, | |
| messages: [{ role: 'user', content: prompt }], | |
| }), | |
| }); | |
| if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`); | |
| const j = await res.json(); | |
| return j.choices?.[0]?.message?.content || '(empty response)'; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Analyze button β for cluster (multi-item) analysis | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('analyse-btn').addEventListener('click', async () => { | |
| const ids = Array.from(selectedItemIds()); | |
| if (ids.length === 0) return; | |
| switchTab('analysis'); | |
| renderAnalysisPane(); | |
| // Trigger cluster analysis automatically after a short delay for rendering | |
| setTimeout(() => runClusterAnalysis(ids), 100); | |
| }); | |
| async function runClusterAnalysis(ids) { | |
| const output = document.getElementById('analysis-output'); | |
| output.className = 'loading'; | |
| output.textContent = `Preparing cluster analysis for ${ids.length} itemsβ¦`; | |
| // Collect summary stats for the cluster | |
| const sims = ids.map(id => state.itemSummary.get(id)?.mean_sim ?? null).filter(v => v !== null); | |
| const meanSim = sims.length ? (sims.reduce((a,b)=>a+b,0)/sims.length * 100).toFixed(1) : 'N/A'; | |
| const minSim = sims.length ? (Math.min(...sims)*100).toFixed(1) : 'N/A'; | |
| const misleadingCount = ids.filter(id => state.itemSummary.get(id)?.is_misleading === true).length; | |
| // ids are already item_id strings from selectedItemIds() | |
| // Read LLM config from form (may have been set by user) | |
| llmConfig.provider = document.getElementById('llm-provider')?.value || llmConfig.provider; | |
| llmConfig.model = document.getElementById('llm-model')?.value || llmConfig.model; | |
| llmConfig.apiKey = document.getElementById('llm-apikey')?.value || llmConfig.apiKey; | |
| llmConfig.endpoint = document.getElementById('llm-endpoint')?.value || llmConfig.endpoint; | |
| // If no API key is set, show a rich mock response | |
| if (!llmConfig.apiKey && llmConfig.provider !== 'local') { | |
| output.className = ''; | |
| output.textContent = generateMockClusterAnalysis(ids, meanSim, minSim, misleadingCount); | |
| return; | |
| } | |
| // Build cluster-level prompt (sample up to 10 items for context length) | |
| const sample = ids.slice(0, 10); | |
| output.textContent = `Loading responses for ${sample.length} sampled itemsβ¦`; | |
| const itemContexts = await Promise.all(sample.map(async id => { | |
| const responses = await loadResponses(id); | |
| const caption = await loadCaption(id); | |
| const s = state.itemSummary.get(id); | |
| const verdicts = Object.entries(responses).map(([m,r]) => { | |
| const resp = r.response || r; | |
| return `${m}: ${resp.is_misleading ? 'misleading' : 'not misleading'}`; | |
| }).join(', '); | |
| return `Item ${id} (mean agreement: ${s ? (s.mean_sim*100).toFixed(0) : '?'}%): | |
| Caption: ${(caption || '(n/a)').substring(0, 120)} | |
| Verdicts: ${verdicts}`; | |
| })); | |
| const prompt = buildClusterPrompt(ids, itemContexts, meanSim, minSim, misleadingCount); | |
| output.textContent = 'Calling APIβ¦'; | |
| try { | |
| let text; | |
| if (llmConfig.provider === 'anthropic') { | |
| text = await callAnthropic(prompt); | |
| } else { | |
| text = await callOpenAI(prompt); | |
| } | |
| output.className = ''; | |
| output.textContent = text; | |
| } catch(e) { | |
| output.className = 'placeholder'; | |
| output.textContent = `API error: ${e.message}\n\nMock response shown instead:\n\n` + generateMockClusterAnalysis(ids, meanSim, minSim, misleadingCount); | |
| } | |
| } | |
| function buildClusterPrompt(ids, itemContexts, meanSim, minSim, misleadingCount) { | |
| return `You are a visualization research expert. You are analyzing a cluster of ${ids.length} images containing data visualizations, selected because they share similar embedding geometry in the "${state.key}" embedding space (experiment: ${state.experiment}). | |
| Cluster statistics: | |
| - Items: ${ids.length} (showing ${itemContexts.length} sampled) | |
| - Misleading: ${misleadingCount} / ${ids.length} | |
| - Mean inter-model agreement: ${meanSim}% | |
| - Min agreement: ${minSim}% | |
| Sampled items: | |
| ${itemContexts.join('\n\n')} | |
| Please analyze: | |
| 1. What common visual or rhetorical features likely caused these items to cluster together? | |
| 2. Is model agreement high or low in this cluster β what does that suggest about this type of visualization? | |
| 3. Are the misleading and non-misleading items separable within this cluster, or mixed? | |
| 4. What would make this cluster analytically useful for studying LLM reasoning on misleading visualizations? | |
| Be specific and concise.`; | |
| } | |
| function generateMockClusterAnalysis(ids, meanSim, minSim, misleadingCount) { | |
| return `[MOCK RESPONSE β configure an API key in the Analysis tab to get a real response] | |
| Cluster Analysis: ${ids.length} items Β· key: ${state.key} Β· experiment: ${state.experiment} | |
| βββ Cluster Characteristics βββ | |
| This cluster contains ${ids.length} items with a mean inter-model agreement of ${meanSim}% and minimum agreement of ${minSim}%. ${misleadingCount} of ${ids.length} items are labeled misleading (${(misleadingCount/ids.length*100).toFixed(0)}%). | |
| βββ Hypothetical Interpretation βββ | |
| Items clustering together in the "${state.key}" embedding space likely share similar semantic patterns in how models discussed this dimension. ${meanSim > 70 ? 'The high mean agreement suggests models converged on similar reasoning for these items β this cluster may represent visualizations with clear, unambiguous misleading mechanisms.' : meanSim > 40 ? 'The moderate agreement level suggests partial consensus β some model pairs agree while others diverge, possibly reflecting ambiguity in the underlying misleading technique.' : 'The low agreement level suggests substantial disagreement between models β this cluster may contain complex or ambiguous visualizations that challenge model reasoning.'} | |
| βββ Suggested Next Steps βββ | |
| β’ Open individual items to inspect the agreement heatmap and compare model responses | |
| β’ Filter by misleading/not-misleading to check if the cluster is semantically coherent | |
| β’ Compare with another experiment (e.g., 1B) to see if providing the verdict label changes the clustering | |
| βββ Note βββ | |
| This is a placeholder. Set your API key, model, and provider in the Analysis tab settings above, then click "Run analysis" on a specific item or re-trigger cluster analysis via the "Analyze selection with LLM" button.`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Tab switching | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function switchTab(tab) { | |
| state.activeTab = tab; | |
| document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.pane === tab)); | |
| renderDetailPane(); | |
| } | |
| document.querySelectorAll('.tab').forEach(t => { | |
| t.addEventListener('click', () => switchTab(t.dataset.pane)); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Controls | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('sel-experiment').addEventListener('change', e => { state.experiment = e.target.value; loadData(); }); | |
| document.getElementById('sel-key').addEventListener('change', e => { state.key = e.target.value; loadData(); }); | |
| document.getElementById('sel-color').addEventListener('change', e => { state.colorBy = e.target.value; renderPlot(); }); | |
| document.getElementById('sel-filter').addEventListener('change', e => { state.filter = e.target.value; renderPlot(); }); | |
| document.getElementById('sel-plotmode').addEventListener('change', e => { | |
| state.plotMode = e.target.value; | |
| clearSelection(); | |
| buildPlotData(); | |
| updateBadge(); | |
| renderPlot(); | |
| renderItemList(Array.from(state.itemSummary.keys())); | |
| }); | |
| document.getElementById('btn-reset').addEventListener('click', () => { loadData(); }); | |
| document.getElementById('btn-clear').addEventListener('click', () => { | |
| clearSelection(); | |
| updateBadge(); | |
| rerenderDots(); | |
| renderItemList(Array.from(state.itemSummary.keys())); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Resize | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.addEventListener('resize', () => { if (state.umapData.length) renderPlot(); }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ID-based selection | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function selectByIds(rawInput) { | |
| const msg = document.getElementById('id-search-msg'); | |
| // Parse: trim whitespace, split on commas and/or newlines, drop empties | |
| const requested = rawInput | |
| .split(/[,\n]+/) | |
| .map(s => s.trim()) | |
| .filter(Boolean); | |
| if (requested.length === 0) { | |
| msg.textContent = 'Enter at least one item ID.'; | |
| msg.style.color = 'var(--accent2)'; | |
| return; | |
| } | |
| const known = new Set(state.itemSummary.keys()); | |
| const found = requested.filter(id => known.has(id)); | |
| const notFound = requested.filter(id => !known.has(id)); | |
| if (found.length === 0) { | |
| msg.textContent = `No matching IDs found (${requested.length} tried).`; | |
| msg.style.color = 'var(--accent2)'; | |
| return; | |
| } | |
| // Apply selection β merge with existing or replace depending on mode | |
| clearSelection(); | |
| found.forEach(id => { | |
| if (state.plotMode === 'B') { | |
| // In mode B, select all model-dots for the given item_id | |
| state.plotData | |
| .filter(d => d.item_id === id) | |
| .forEach(d => state.selectedKeys.add(`${d.item_id}::${d.model}`)); | |
| } else { | |
| state.selectedIds.add(id); | |
| } | |
| }); | |
| updateBadge(); | |
| rerenderDots(); | |
| renderItemList(Array.from(found)); | |
| // Auto-open the first found item in the detail panel | |
| if (found.length === 1) selectItem(found[0]); | |
| // Feedback message | |
| if (notFound.length === 0) { | |
| msg.textContent = `${found.length} item${found.length > 1 ? 's' : ''} selected.`; | |
| msg.style.color = 'var(--accent3)'; | |
| } else { | |
| msg.textContent = `${found.length} selected Β· ${notFound.length} not found: ${notFound.slice(0,3).join(', ')}${notFound.length > 3 ? 'β¦' : ''}`; | |
| msg.style.color = 'var(--yellow)'; | |
| } | |
| } | |
| document.getElementById('id-search-btn').addEventListener('click', () => { | |
| selectByIds(document.getElementById('id-search-input').value); | |
| }); | |
| document.getElementById('id-search-input').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') selectByIds(e.target.value); | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Theme toggle | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (function() { | |
| const btn = document.getElementById('btn-theme'); | |
| const body = document.body; | |
| // Restore saved preference | |
| const saved = localStorage.getItem('tvl-theme'); | |
| if (saved === 'light') { body.classList.add('light'); btn.textContent = 'β Light'; } | |
| btn.addEventListener('click', () => { | |
| const isLight = body.classList.toggle('light'); | |
| btn.textContent = isLight ? 'β Light' : 'βΎ Dark'; | |
| localStorage.setItem('tvl-theme', isLight ? 'light' : 'dark'); | |
| // Re-render plot so SVG text colors update (they use JS-computed colors) | |
| if (state.plotData?.length) renderPlot(); | |
| }); | |
| })(); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Boot | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try { | |
| await initDuckDB(); | |
| setStatus('DuckDB ready', 'ready'); | |
| await loadData(); | |
| } catch(e) { | |
| setStatus(`Boot error: ${e.message}`, 'error'); | |
| console.error(e); | |
| } | |
| </script> | |
| </body> | |
| </html> | |