File size: 8,330 Bytes
d4ec2fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""
Ticket 1 — Ingest ASRS Dataset.

Loads a CSV (synthetic sample OR a real ASRS Database Online export) into a
normalized SQLite `reports` table, per the schema in the Technical Architecture doc.

Design note: the real official ASRS CSV export uses column names like
"ACN", "Date / Local Time Of Day", "Aircraft 1 / Make Model Name",
"Aircraft 1 / Flight Phase", "Report 1 / Narrative", etc. (see the qge/ASRS
schema dump for the exact field names). COLUMN_MAP below is the single place
to adjust if the real export's column headers differ slightly from what's
listed here — nothing else in this script should need to change.

Usage:
    python training/preprocess.py --input data/raw/asrs_sample.csv --db data/processed/reports.db
"""
import argparse
import csv
import html
import re
import sqlite3
from pathlib import Path

# Map: our normalized field -> possible source column names (checked in order)
# Confirmed against the real ASRS Database Online CSV export header (July 2026).
COLUMN_MAP = {
    "asrs_report_id": ["ACN", "__ACN", "Person_1__ASRS_Report_Number.Accession_Number"],
    "narrative_text": ["Narrative", "Report_1__Narrative"],
    "report_date": ["Date", "Time__Date"],
    "aircraft_type": ["Make Model Name", "Aircraft_Make_Model", "Aircraft_1__Make_Model_Name"],
    "phase_of_flight": ["Flight Phase", "Flight_Phase", "Aircraft_1__Flight_Phase"],
}

CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS reports (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    asrs_report_id TEXT UNIQUE NOT NULL,
    narrative_text TEXT NOT NULL,
    report_date TEXT,
    aircraft_type TEXT,
    phase_of_flight TEXT,
    predicted_ata_chapter TEXT,
    predicted_severity TEXT,
    ata_confidence REAL,
    severity_confidence REAL,
    embedding_id TEXT
);
"""


def clean_text(raw: str) -> str:
    """Strip HTML entities/tags and normalize whitespace from a raw narrative."""
    if raw is None:
        return ""
    text = html.unescape(raw)
    text = re.sub(r"<[^>]+>", " ", text)          # strip stray HTML tags
    text = re.sub(r"\s+", " ", text).strip()       # collapse whitespace
    return text


def find_column(row_keys, candidates):
    for c in candidates:
        if c in row_keys:
            return c
    return None


def is_official_export(input_file: Path) -> bool:
    """Detect the real ASRS Database Online export: first data cell of row 1 is
    blank (category header row), and row 2's first field is 'ACN'."""
    with input_file.open("r", encoding="utf-8-sig", newline="") as f:
        reader = csv.reader(f)
        try:
            row0 = next(reader)
            row1 = next(reader)
        except StopIteration:
            return False
    return len(row0) > 1 and row0[0].strip() == "" and row1 and row1[0].strip() == "ACN"


def parse_official_date(raw: str):
    """Official export dates are 'YYYYMM' (e.g. '202101') -> '2021-01-01'."""
    raw = (raw or "").strip()
    if len(raw) == 6 and raw.isdigit():
        return f"{raw[:4]}-{raw[4:6]}-01"
    return raw or None


def ingest_official(input_file: Path, conn: sqlite3.Connection):
    """Ingest the real ASRS Database Online CSV export (two-row header,
    duplicate column names like 'Narrative' appearing for each reporter)."""
    with input_file.open("r", encoding="utf-8-sig", newline="") as f:
        reader = csv.reader(f)
        rows = list(reader)

    field_row = rows[1]  # row 0 is the category header, row 1 is field names
    data_rows = [r for r in rows[2:] if any(cell.strip() for cell in r)]

    def idx(name):
        try:
            return field_row.index(name)  # first occurrence = primary reporter's field
        except ValueError:
            return None

    i_acn = idx("ACN")
    i_date = idx("Date")
    i_aircraft = idx("Make Model Name")
    i_phase = idx("Flight Phase")
    i_narrative = idx("Narrative")

    if i_acn is None or i_narrative is None:
        raise ValueError("Could not find required 'ACN' or 'Narrative' columns in official export.")

    inserted, skipped_dupe, skipped_empty = 0, 0, 0
    for row in data_rows:
        if len(row) <= max(i_acn, i_narrative):
            skipped_empty += 1
            continue

        asrs_id = row[i_acn].strip()
        narrative = clean_text(row[i_narrative])
        if not asrs_id or not narrative:
            skipped_empty += 1
            continue

        report_date = parse_official_date(row[i_date]) if i_date is not None and i_date < len(row) else None
        aircraft_type = row[i_aircraft].strip() if i_aircraft is not None and i_aircraft < len(row) else None
        phase = row[i_phase].strip() if i_phase is not None and i_phase < len(row) else None

        try:
            conn.execute(
                """INSERT INTO reports
                   (asrs_report_id, narrative_text, report_date, aircraft_type, phase_of_flight)
                   VALUES (?, ?, ?, ?, ?)""",
                (asrs_id, narrative, report_date, aircraft_type, phase),
            )
            inserted += 1
        except sqlite3.IntegrityError:
            skipped_dupe += 1

    return inserted, skipped_dupe, skipped_empty


def ingest(input_path: str, db_path: str):
    input_file = Path(input_path)
    if not input_file.exists():
        raise FileNotFoundError(f"Input CSV not found: {input_file}")

    Path(db_path).parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(db_path)
    conn.execute(CREATE_TABLE_SQL)

    if is_official_export(input_file):
        print("Detected official ASRS Database Online export format.")
        inserted, skipped_dupe, skipped_empty = ingest_official(input_file, conn)
        conn.commit()
        total = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
        conn.close()
        print(f"Inserted: {inserted}")
        print(f"Skipped (duplicate asrs_report_id): {skipped_dupe}")
        print(f"Skipped (empty id/narrative): {skipped_empty}")
        print(f"Total rows now in reports table: {total}")
        return

    inserted, skipped_dupe, skipped_empty = 0, 0, 0

    with input_file.open("r", encoding="utf-8-sig", newline="") as f:
        reader = csv.DictReader(f)
        row_keys = reader.fieldnames or []

        resolved = {}
        for field, candidates in COLUMN_MAP.items():
            col = find_column(row_keys, candidates)
            if col is None and field in ("asrs_report_id", "narrative_text"):
                raise ValueError(
                    f"Required field '{field}' not found. Looked for: {candidates}. "
                    f"Found columns: {row_keys}"
                )
            resolved[field] = col

        for row in reader:
            asrs_id = str(row.get(resolved["asrs_report_id"], "")).strip()
            narrative = clean_text(row.get(resolved["narrative_text"], ""))

            if not asrs_id or not narrative:
                skipped_empty += 1
                continue

            report_date = row.get(resolved["report_date"]) if resolved["report_date"] else None
            aircraft_type = row.get(resolved["aircraft_type"]) if resolved["aircraft_type"] else None
            phase = row.get(resolved["phase_of_flight"]) if resolved["phase_of_flight"] else None

            try:
                conn.execute(
                    """INSERT INTO reports
                       (asrs_report_id, narrative_text, report_date, aircraft_type, phase_of_flight)
                       VALUES (?, ?, ?, ?, ?)""",
                    (asrs_id, narrative, report_date, aircraft_type, phase),
                )
                inserted += 1
            except sqlite3.IntegrityError:
                # UNIQUE constraint on asrs_report_id -> duplicate, upsert-by-skip
                skipped_dupe += 1

    conn.commit()
    total = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
    conn.close()

    print(f"Inserted: {inserted}")
    print(f"Skipped (duplicate asrs_report_id): {skipped_dupe}")
    print(f"Skipped (empty id/narrative): {skipped_empty}")
    print(f"Total rows now in reports table: {total}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", default="data/raw/asrs_sample.csv")
    parser.add_argument("--db", default="data/processed/reports.db")
    args = parser.parse_args()
    ingest(args.input, args.db)