From 3db2c963d051e148fb0662bc9851230dfc879f99 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 26 Aug 2025 20:57:17 -0400 Subject: [PATCH] Add CSV export feature with download button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create csv_exporter.py module to flatten experiment data into CSV format - Automatically generate experiment_data.csv on every build - Add download button to UI header with 📥 icon - Export one row per scenario decision with all relevant fields - Support both uniform and mixed KDMA experiments - Include columns for experiment path, ADM name, LLM, KDMA config, choices, justifications, timing, and scores - Enable pivot table analysis in Excel/Google Sheets --- align_browser/build.py | 5 + align_browser/csv_exporter.py | 213 ++++++++++++++++++++++++++++++++ align_browser/static/app.js | 17 +++ align_browser/static/index.html | 23 ++-- align_browser/static/style.css | 15 +++ 5 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 align_browser/csv_exporter.py diff --git a/align_browser/build.py b/align_browser/build.py index 07e9248..ca06636 100644 --- a/align_browser/build.py +++ b/align_browser/build.py @@ -15,6 +15,7 @@ build_manifest_from_experiments, copy_experiment_files, ) +from align_browser.csv_exporter import write_experiments_to_csv def copy_static_assets(output_dir): @@ -74,6 +75,10 @@ def build_frontend( with open(data_output_dir / "manifest.json", "w") as f: json.dump(manifest.model_dump(), f, indent=2) + # Generate CSV export + csv_output_path = data_output_dir / "experiment_data.csv" + write_experiments_to_csv(experiments, experiments_root, csv_output_path) + return output_dir diff --git a/align_browser/csv_exporter.py b/align_browser/csv_exporter.py new file mode 100644 index 0000000..bf62dac --- /dev/null +++ b/align_browser/csv_exporter.py @@ -0,0 +1,213 @@ +"""CSV export functionality for experiment data.""" + +import csv +from pathlib import Path +from typing import List, Dict, Any, Optional +from align_browser.experiment_models import ( + ExperimentData, + InputOutputItem, + parse_alignment_target_id, +) + + +def format_kdma_config(kdma_values: List[Dict[str, Any]]) -> str: + """Format KDMA values as a configuration string.""" + if not kdma_values: + return "unaligned" + + kdma_strings = [] + for kdma in kdma_values: + kdma_name = kdma.get("kdma", "unknown") + value = kdma.get("value", 0.0) + kdma_strings.append(f"{kdma_name}:{value}") + + return ",".join(kdma_strings) + + +def extract_choice_text(item: InputOutputItem) -> str: + """Extract the human-readable choice text from an input/output item.""" + if not item.output or "choice" not in item.output: + return "" + + choice_index = item.output.get("choice") + if choice_index is None: + return "" + + choices = item.input.choices + if not choices or choice_index >= len(choices): + return "" + + selected_choice = choices[choice_index] + return selected_choice.get("unstructured", "") + + +def extract_choice_kdma(item: InputOutputItem) -> str: + """Extract the KDMA association for the selected choice.""" + if not item.output or "choice" not in item.output: + return "" + + choice_index = item.output.get("choice") + if choice_index is None: + return "" + + choices = item.input.choices + if not choices or choice_index >= len(choices): + return "" + + selected_choice = choices[choice_index] + return selected_choice.get("kdma_association", "") + + +def extract_justification(item: InputOutputItem) -> str: + """Extract the justification from an input/output item.""" + if not item.output: + return "" + + action = item.output.get("action", {}) + return action.get("justification", "") + + +def get_decision_time( + timing_data: Optional[Dict[str, Any]], item_index: int +) -> Optional[float]: + """Get the decision time for a specific item index from timing data.""" + if not timing_data or "scenarios" not in timing_data: + return None + + scenarios = timing_data.get("scenarios", []) + if item_index >= len(scenarios): + return None + + scenario_timing = scenarios[item_index] + raw_times = scenario_timing.get("raw_times_s", []) + + # Use the first raw time as the decision time for this scenario + if raw_times: + return raw_times[0] + + # Fallback to average time if raw times not available + return scenario_timing.get("avg_time_s") + + +def get_score( + scores_data: Optional[Dict[str, Any]], item_index: int +) -> Optional[float]: + """Get the score for a specific item index from scores data.""" + if not scores_data or "scores" not in scores_data: + return None + + scores_list = scores_data.get("scores", []) + if item_index >= len(scores_list): + return None + + score_item = scores_list[item_index] + return score_item.get("score") + + +def experiment_to_csv_rows( + experiment: ExperimentData, experiments_root: Path +) -> List[Dict[str, Any]]: + """Convert an experiment to CSV rows (one per scenario decision).""" + rows = [] + + # Get relative experiment path + relative_path = experiment.experiment_path.relative_to(experiments_root) + experiment_path_str = str(relative_path) + + # Get experiment metadata + config = experiment.config + adm_name = config.adm.name if config.adm else "unknown" + llm_backbone = ( + config.adm.llm_backbone if config.adm and config.adm.llm_backbone else "no_llm" + ) + run_variant = config.run_variant if config.run_variant else "default" + + # Parse alignment target to get KDMA config + alignment_target_id = ( + config.alignment_target.id if config.alignment_target else "unaligned" + ) + kdma_values = parse_alignment_target_id(alignment_target_id) + kdma_config = format_kdma_config( + [{"kdma": kv.kdma, "value": kv.value} for kv in kdma_values] + ) + + # Load timing data if available + timing_data = None + if experiment.timing: + timing_data = experiment.timing.model_dump() + + # Load scores data if available + scores_data = None + if experiment.scores: + scores_data = experiment.scores.model_dump() + + # Process each input/output item + for idx, item in enumerate(experiment.input_output.data): + row = { + "experiment_path": experiment_path_str, + "adm_name": adm_name, + "llm_backbone": llm_backbone, + "run_variant": run_variant, + "kdma_config": kdma_config, + "alignment_target_id": alignment_target_id, + "scenario_id": item.input.scenario_id, + "state_description": item.input.state + if hasattr(item.input, "state") + else "", + "choice_text": extract_choice_text(item), + "choice_kdma_association": extract_choice_kdma(item), + "justification": extract_justification(item), + "decision_time_s": get_decision_time(timing_data, idx), + "score": get_score(scores_data, idx), + } + + # Convert None values to empty strings for CSV + for key, value in row.items(): + if value is None: + row[key] = "" + + rows.append(row) + + return rows + + +def write_experiments_to_csv( + experiments: List[ExperimentData], experiments_root: Path, output_file: Path +) -> None: + """Write all experiments to a CSV file.""" + + # Define CSV columns in order + fieldnames = [ + "experiment_path", + "adm_name", + "llm_backbone", + "run_variant", + "kdma_config", + "alignment_target_id", + "scenario_id", + "state_description", + "choice_text", + "choice_kdma_association", + "justification", + "decision_time_s", + "score", + ] + + all_rows = [] + + # Convert all experiments to CSV rows + for experiment in experiments: + try: + rows = experiment_to_csv_rows(experiment, experiments_root) + all_rows.extend(rows) + except Exception as e: + print( + f"Warning: Failed to export experiment {experiment.experiment_path}: {e}" + ) + continue + + # Write CSV file + with open(output_file, "w", newline="", encoding="utf-8") as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(all_rows) diff --git a/align_browser/static/app.js b/align_browser/static/app.js index 62ba0d1..f4a9352 100644 --- a/align_browser/static/app.js +++ b/align_browser/static/app.js @@ -43,6 +43,17 @@ function preserveLinkedParameters(validatedParams, originalParams, appState) { return preserved; } +// CSV Download functionality +function downloadCSV() { + const csvPath = './data/experiment_data.csv'; + const link = document.createElement('a'); + link.href = csvPath; + link.download = 'experiment_data.csv'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + document.addEventListener("DOMContentLoaded", () => { // UI state persistence for expandable content @@ -1084,6 +1095,12 @@ document.addEventListener("DOMContentLoaded", () => { addColumnBtn.addEventListener('click', copyColumn); } + // Add CSV download button event listener + const downloadCsvBtn = document.getElementById('download-csv-btn'); + if (downloadCsvBtn) { + downloadCsvBtn.addEventListener('click', downloadCSV); + } + // Initial manifest fetch on page load fetchManifest(); }); diff --git a/align_browser/static/index.html b/align_browser/static/index.html index ec30e51..97071dc 100644 --- a/align_browser/static/index.html +++ b/align_browser/static/index.html @@ -15,13 +15,22 @@

- +
+ + +
diff --git a/align_browser/static/style.css b/align_browser/static/style.css index 2990a0c..2c5214d 100644 --- a/align_browser/static/style.css +++ b/align_browser/static/style.css @@ -47,6 +47,12 @@ footer { padding-bottom: 15px; } +.header-buttons { + display: flex; + gap: 10px; + align-items: center; +} + .table-header h2 { margin: 0; color: #495057; @@ -341,6 +347,15 @@ footer { background-color: #0056b3; } +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background-color: #5a6268; +} + .btn:disabled { background-color: #6c757d;