CoolFace
Apppublic

aops02/math-annotation-demo

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
prepare_potato_data.py368 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""Prepare Potato annotation data from the math annotator Excel workbook.3 4This script intentionally uses only the Python standard library so it can run5in a fresh environment without openpyxl/pandas.6"""7 8from __future__ import annotations9 10import argparse11import csv12import html13import json14import re15import sys16import textwrap17import zipfile18from collections import defaultdict19from pathlib import Path20from xml.etree import ElementTree as ET21 22 23ROOT = Path(__file__).resolve().parents[2]24DEFAULT_INPUT = ROOT / "annotated_data" / "math_annotator_train&test_sets_simple_2q_train_2q_test.xlsx"25DEFAULT_OUTPUT_DIR = Path(__file__).resolve().parents[1] / "my-annotation-task" / "data"26 27SHEET_TO_SPLIT = {28    "math_annotator_training_set": "train",29    "math_annotator_testing_set": "test",30}31 32DISPLAY_COLUMNS = ["dialog_context", "correct_solution", "tutor_response"]33 34DIMENSIONS = [35    "Content Correctness",36    "Learner-State Assessment",37    "Issue Localization",38    "Disclosure Appropriateness",39    "Providing Guidance",40    "Coherence",41    "Actionability",42    "Clarity",43    "Conciseness",44    "Humanness",45]46 47VALID_LABELS = {"Yes", "To some extent", "No"}48 49NS = {50    "a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",51    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",52}53 54 55def column_index(cell_ref: str) -> int:56    match = re.match(r"([A-Z]+)", cell_ref or "A")57    if not match:58        return 059    index = 060    for char in match.group(1):61        index = index * 26 + (ord(char) - ord("A") + 1)62    return index - 163 64 65def load_shared_strings(archive: zipfile.ZipFile) -> list[str]:66    if "xl/sharedStrings.xml" not in archive.namelist():67        return []68    root = ET.fromstring(archive.read("xl/sharedStrings.xml"))69    strings: list[str] = []70    for string_item in root.findall("a:si", NS):71        strings.append("".join(node.text or "" for node in string_item.findall(".//a:t", NS)))72    return strings73 74 75def get_cell_text(cell: ET.Element, shared_strings: list[str]) -> str:76    cell_type = cell.attrib.get("t")77    value_node = cell.find("a:v", NS)78    if cell_type == "s" and value_node is not None and value_node.text:79        return shared_strings[int(value_node.text)]80    if cell_type == "inlineStr":81        return "".join(node.text or "" for node in cell.findall(".//a:t", NS))82    if value_node is not None:83        return value_node.text or ""84    return ""85 86 87def resolve_sheet_path(target: str) -> str:88    target = target.lstrip("/")89    if target.startswith("xl/"):90        return target91    return f"xl/{target}"92 93 94def read_workbook(path: Path) -> dict[str, list[list[str]]]:95    with zipfile.ZipFile(path) as archive:96        shared_strings = load_shared_strings(archive)97        workbook_root = ET.fromstring(archive.read("xl/workbook.xml"))98        rels_root = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels"))99        relationship_targets = {100            rel.attrib["Id"]: rel.attrib["Target"]101            for rel in rels_root102        }103 104        sheets: dict[str, list[list[str]]] = {}105        for sheet in workbook_root.find("a:sheets", NS):106            sheet_name = sheet.attrib["name"]107            rel_id = sheet.attrib[f"{{{NS['r']}}}id"]108            sheet_path = resolve_sheet_path(relationship_targets[rel_id])109            sheet_root = ET.fromstring(archive.read(sheet_path))110            rows: list[list[str]] = []111 112            for row in sheet_root.findall(".//a:sheetData/a:row", NS):113                values_by_col = {114                    column_index(cell.attrib.get("r", "A")): get_cell_text(cell, shared_strings)115                    for cell in row.findall("a:c", NS)116                }117                if values_by_col:118                    width = max(values_by_col) + 1119                    rows.append([values_by_col.get(col, "") for col in range(width)])120                else:121                    rows.append([])122            sheets[sheet_name] = rows123    return sheets124 125 126def normalize_rows(raw_rows: list[list[str]]) -> list[dict[str, str]]:127    nonempty_rows = [row for row in raw_rows if any(str(value).strip() for value in row)]128    if not nonempty_rows:129        return []130 131    header = [str(value).strip() for value in nonempty_rows[0]]132    rows: list[dict[str, str]] = []133    for raw_row in nonempty_rows[1:]:134        padded = raw_row + [""] * (len(header) - len(raw_row))135        rows.append({header[index]: str(padded[index]).strip() for index in range(len(header))})136    return rows137 138 139def slugify(value: str) -> str:140    value = value.lower().strip()141    value = re.sub(r"[^a-z0-9]+", "_", value)142    return value.strip("_") or "item"143 144 145def escape_text_node(text: str) -> str:146    """Escape HTML text content without turning quotes into visible entities."""147    return html.escape(html.unescape(text), quote=False)148 149 150def inline_format(text: str) -> str:151    escaped = escape_text_node(text)152    escaped = re.sub(r"`([^`]+)`", r"<code>\1</code>", escaped)153    escaped = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", escaped)154    escaped = re.sub(155        r"(?m)^(Tutor|Student):",156        lambda match: f"<strong>{match.group(1)}:</strong>",157        escaped,158    )159    return escaped160 161 162def rich_text(text: str) -> str:163    text = (text or "").strip()164    if not text:165        return "<p><em>No content provided.</em></p>"166 167    parts = re.split(r"(```(?:[a-zA-Z0-9_+-]+)?\n.*?\n```)", text, flags=re.DOTALL)168    rendered: list[str] = []169    for part in parts:170        if not part:171            continue172        fence_match = re.match(r"```(?:[a-zA-Z0-9_+-]+)?\n(.*?)\n```", part, flags=re.DOTALL)173        if fence_match:174            code = escape_text_node(fence_match.group(1).strip("\n"))175            rendered.append(f'<pre class="potato-code-block"><code>{code}</code></pre>')176            continue177 178        paragraphs = [paragraph.strip() for paragraph in re.split(r"\n\s*\n", part) if paragraph.strip()]179        for paragraph in paragraphs:180            paragraph_html = inline_format(paragraph).replace("\n", "<br>")181            rendered.append(f"<p>{paragraph_html}</p>")182    return "\n".join(rendered)183 184 185def section_html(title: str, body: str, *, extra_class: str = "") -> str:186    class_name = "potato-text-section"187    if extra_class:188        class_name = f"{class_name} {extra_class}"189    return (190        f'<div class="{class_name}">'191        f"<h3>{html.escape(title)}</h3>"192        f"{rich_text(body)}"193        "</div>"194    )195 196 197def make_text2show(row: dict[str, str]) -> str:198    return textwrap.dedent(199        f"""\200        Dialog context:201        {row["dialog_context"]}202 203        Reference (correct) solution:204        {row["correct_solution"]}205 206        Next tutor response:207        {row["tutor_response"]}208        """209    ).strip()210 211 212def make_text2show_html(row: dict[str, str], split: str, item_id: str) -> str:213    split_label = split.upper()214    return "\n".join(215        [216            f'<div class="potato-instance-meta"><span>{split_label}</span><span>{html.escape(item_id)}</span></div>',217            section_html("Dialog context", row["dialog_context"]),218            section_html("Reference (correct) solution", row["correct_solution"], extra_class="reference-solution"),219            section_html("Next tutor response", row["tutor_response"], extra_class="tutor-response"),220        ]221    )222 223 224def validate_labels(row: dict[str, str], row_id: str) -> None:225    for dimension in DIMENSIONS:226        label = row.get(dimension, "").strip()227        if label not in VALID_LABELS:228            raise ValueError(f"{row_id}: invalid label for {dimension!r}: {label!r}")229 230 231def prepare_records(workbook_path: Path) -> dict[str, list[dict[str, str]]]:232    sheets = read_workbook(workbook_path)233    missing_sheets = [sheet for sheet in SHEET_TO_SPLIT if sheet not in sheets]234    if missing_sheets:235        raise ValueError(f"Missing expected sheets: {missing_sheets}")236 237    records_by_split: dict[str, list[dict[str, str]]] = {}238    for sheet_name, split in SHEET_TO_SPLIT.items():239        rows = normalize_rows(sheets[sheet_name])240        if not rows:241            raise ValueError(f"Sheet {sheet_name!r} has no data rows")242 243        required = DISPLAY_COLUMNS + DIMENSIONS244        missing_columns = sorted({column for column in required if column not in rows[0]})245        if missing_columns:246            raise ValueError(f"Sheet {sheet_name!r} missing columns: {missing_columns}")247 248        dialog_numbers: dict[str, int] = {}249        response_counts: defaultdict[int, int] = defaultdict(int)250        split_records: list[dict[str, str]] = []251 252        for row in rows:253            dialog_context = row["dialog_context"]254            if dialog_context not in dialog_numbers:255                dialog_numbers[dialog_context] = len(dialog_numbers) + 1256            dialog_id = dialog_numbers[dialog_context]257            response_counts[dialog_id] += 1258            response_id = response_counts[dialog_id]259            item_id = f"math_{split}_q{dialog_id:02d}_r{response_id:02d}"260 261            validate_labels(row, item_id)262 263            record = {264                "id": item_id,265                "split": split,266                "domain": "math",267                "dialog_id": f"q{dialog_id:02d}",268                "response_id": f"r{response_id:02d}",269                "text2show": make_text2show(row),270                "text2show_html": make_text2show_html(row, split, item_id),271            }272            for column in DISPLAY_COLUMNS + DIMENSIONS:273                record[column] = row[column]274            split_records.append(record)275        records_by_split[split] = split_records276    return records_by_split277 278 279def write_csv(path: Path, rows: list[dict[str, str]]) -> None:280    fieldnames = [281        "id",282        "split",283        "domain",284        "dialog_id",285        "response_id",286        "text2show",287        "text2show_html",288        *DISPLAY_COLUMNS,289        *DIMENSIONS,290    ]291    with path.open("w", newline="", encoding="utf-8") as handle:292        writer = csv.DictWriter(handle, fieldnames=fieldnames)293        writer.writeheader()294        writer.writerows(rows)295 296 297def to_gold_item(record: dict[str, str], *, key_name: str) -> dict[str, object]:298    labels = {dimension: record[dimension] for dimension in DIMENSIONS}299    return {300        "id": record["id"],301        "text": record["text2show"],302        "text2show": record["text2show"],303        "text2show_html": record["text2show_html"],304        key_name: labels,305        "explanation": "Gold labels are provided by the curated math annotator training/test workbook.",306        "metadata": {307            "split": record["split"],308            "domain": record["domain"],309            "dialog_id": record["dialog_id"],310            "response_id": record["response_id"],311        },312    }313 314 315def write_json(path: Path, payload: object) -> None:316    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")317 318 319def main() -> int:320    parser = argparse.ArgumentParser(description=__doc__)321    parser.add_argument("--input", type=Path, default=DEFAULT_INPUT, help="Source .xlsx workbook")322    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Output data directory")323    args = parser.parse_args()324 325    if not args.input.exists():326        print(f"Input workbook not found: {args.input}", file=sys.stderr)327        return 1328 329    records_by_split = prepare_records(args.input)330    output_dir = args.output_dir331    output_dir.mkdir(parents=True, exist_ok=True)332 333    all_records = records_by_split["train"] + records_by_split["test"]334    write_csv(output_dir / "math_annotator_training_set_with_id_text2show.csv", records_by_split["train"])335    write_csv(output_dir / "math_annotator_testing_set_with_id_text2show.csv", records_by_split["test"])336    write_csv(output_dir / "math_annotator_demo_all_with_id_text2show.csv", all_records)337 338    write_json(339        output_dir / "training_questions.json",340        [to_gold_item(record, key_name="correct_answers") for record in records_by_split["train"]],341    )342    write_json(343        output_dir / "gold_standards.json",344        [to_gold_item(record, key_name="gold_label") for record in records_by_split["test"]],345    )346 347    summary = {348        "input": str(args.input),349        "outputs": {350            "train_csv": "math_annotator_training_set_with_id_text2show.csv",351            "test_csv": "math_annotator_testing_set_with_id_text2show.csv",352            "combined_csv": "math_annotator_demo_all_with_id_text2show.csv",353            "training_questions": "training_questions.json",354            "gold_standards": "gold_standards.json",355        },356        "counts": {split: len(records) for split, records in records_by_split.items()},357        "dimensions": DIMENSIONS,358        "labels": sorted(VALID_LABELS),359    }360    write_json(output_dir / "data_summary.json", summary)361 362    print(json.dumps(summary, ensure_ascii=False, indent=2))363    return 0364 365 366if __name__ == "__main__":367    raise SystemExit(main())368