rudeparis commited on
Commit
77caa29
·
verified ·
1 Parent(s): 1269a58

Upload build_canonical_v1.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. build_canonical_v1.py +201 -0
build_canonical_v1.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build canonical-v1 corpus: multi-segment JSON schema + unknown class + hard negatives.
2
+
3
+ Schema (always emit `segments` list, even for single-intent):
4
+ {"segments": [{"intent": "...", "slots": {...}, "text": "..."}], "abstain_reason": null}
5
+
6
+ Sources:
7
+ - data/src/partner_graph/output/scenarios/*.json
8
+ - 442 single-intent ATC turns -> 1-segment list
9
+ - 76 compound turns with expected_segments[] -> N-segment list
10
+ - V8 failures from eval_dump_v8_canonical.json -> hard-negative gold-corrected
11
+ - Synthetic `unknown` adversarials (garbled / corrupted transcripts) -> abstain training
12
+
13
+ Output: poc/llm-finetune/training/data_canonical_v1/{train,valid,test}.jsonl
14
+ """
15
+ from __future__ import annotations
16
+ import json
17
+ import random
18
+ import re
19
+ from collections import defaultdict
20
+ from pathlib import Path
21
+
22
+ ROOT = Path("/Users/jean-patricksmith/digital/kingly/apps/production/naac")
23
+ INTENTS_50 = sorted(json.loads((ROOT / "poc/deberta_intent/checkpoints-base/label_mapping.json").read_text())["intent2id"].keys())
24
+ INTENTS_51 = INTENTS_50 + ["unknown"]
25
+ SLOTS_LOWER = ["altimeter_setting", "altitude", "approach_type", "call_sign", "clock_position",
26
+ "direction", "distance", "facility", "fix", "frequency", "heading", "pattern_leg",
27
+ "route", "runway", "speed", "taxiway", "time", "transponder_code", "turn_direction",
28
+ "sequence"]
29
+
30
+ SYSTEM_PROMPT = (
31
+ "You are an ATC parser. Parse the air traffic control transmission into a JSON object.\n\n"
32
+ "OUTPUT SCHEMA (always emit `segments` list, even single-intent turns):\n"
33
+ '{\n "segments": [\n {"intent": <one-of-enum>, "slots": {<lowercase_key>: <value>}, "text": <segment substring>}\n ],\n "abstain_reason": null | <short reason if unsure>\n}\n\n'
34
+ "Intent enum (51 values, includes `unknown` for ambiguous/garbled):\n"
35
+ + ", ".join(INTENTS_51) + "\n\n"
36
+ "Slot keys (lowercase only): " + ", ".join(SLOTS_LOWER) + "\n\n"
37
+ "Rules:\n"
38
+ "1. Compound transmissions get multiple segments — split by comma, period, or new clause.\n"
39
+ "2. Single-intent transmissions still emit one segment in the list.\n"
40
+ "3. Use `unknown` intent + `abstain_reason` when transcript is garbled, partial, or doesn't match any enum.\n"
41
+ "4. Slots is FMM-relevant subset only — do NOT include callsign or facility unless required for the action.\n"
42
+ "5. Output ONLY the JSON object."
43
+ )
44
+
45
+
46
+ def to_chat(text: str, segments: list[dict], abstain_reason: str | None = None) -> dict:
47
+ payload = {"segments": segments}
48
+ if abstain_reason:
49
+ payload["abstain_reason"] = abstain_reason
50
+ else:
51
+ payload["abstain_reason"] = None
52
+ gold = json.dumps(payload, ensure_ascii=False)
53
+ return {
54
+ "messages": [
55
+ {"role": "system", "content": SYSTEM_PROMPT},
56
+ {"role": "user", "content": text.strip()},
57
+ {"role": "assistant", "content": gold},
58
+ ]
59
+ }
60
+
61
+
62
+ def normalize_segment(seg: dict) -> dict:
63
+ """Convert a raw expected_segment dict to canonical (lowercase keys, FMM subset)."""
64
+ intent = seg.get("intent")
65
+ if intent not in INTENTS_50:
66
+ return None
67
+ slots_raw = seg.get("slots") or {}
68
+ slots = {k.lower(): str(v) for k, v in slots_raw.items() if v is not None and str(v) != ""}
69
+ text = (seg.get("text") or "").strip()
70
+ return {"intent": intent, "slots": slots, "text": text}
71
+
72
+
73
+ def walk_canonical_turns():
74
+ """Yield (text, segments_list, source) for each ATC-spoken turn."""
75
+ scenario_dir = ROOT / "data/src/partner_graph/output/scenarios"
76
+ for path in sorted(scenario_dir.glob("*.json")):
77
+ try:
78
+ scn = json.loads(path.read_text())
79
+ except Exception:
80
+ continue
81
+ for turn in scn.get("turns", []):
82
+ if turn.get("speaker", "").lower() != "atc":
83
+ continue
84
+ text = (turn.get("expected_transcript") or "").strip()
85
+ if not text:
86
+ continue
87
+
88
+ # Compound turn → use expected_segments
89
+ if turn.get("compound") and turn.get("expected_segments"):
90
+ segs = []
91
+ for s in turn["expected_segments"]:
92
+ norm = normalize_segment(s)
93
+ if norm:
94
+ segs.append(norm)
95
+ if not segs:
96
+ continue
97
+ yield text, segs, "compound"
98
+ else:
99
+ # Single intent
100
+ intent = turn.get("expected_intent")
101
+ if intent not in INTENTS_50:
102
+ continue
103
+ params = turn.get("expected_parameters") or {}
104
+ slots = {k.lower(): str(v) for k, v in params.items() if v is not None and str(v) != ""}
105
+ yield text, [{"intent": intent, "slots": slots, "text": text}], "single"
106
+
107
+
108
+ def mine_v8_failures():
109
+ """Yield (text, segments_list, source='hard_negative') from V8 failures with corrected gold."""
110
+ dump = json.loads((ROOT / "poc/llm-finetune/training/eval_dump_v8_canonical.json").read_text())
111
+ for r in dump["rows"]:
112
+ gold_intent = r["gold_intent"]
113
+ gold_params = r["gold_parameters"] or {}
114
+ slots = {k.lower(): str(v) for k, v in gold_params.items() if v is not None and str(v) != ""}
115
+ # Re-emit gold as single-segment hard-negative (not yet compound-aware in canonical-v0)
116
+ yield r["text"], [{"intent": gold_intent, "slots": slots, "text": r["text"]}], "hard_negative"
117
+
118
+
119
+ def synthesize_unknowns():
120
+ """Synthesize `unknown`-intent adversarial examples for abstention training."""
121
+ examples = [
122
+ ("zzzkkrrr static partial garbled transmission unintelligible", "garbled_transcript"),
123
+ ("station calling please say again", "incomplete_transmission"),
124
+ ("uhh what was that", "non_atc_speech"),
125
+ ("hello world this is a test", "out_of_domain"),
126
+ ("the cat sat on the mat", "out_of_domain"),
127
+ ("November One Seven X-ray X-ray, kkkrr static break sssh", "partial_transmission"),
128
+ ("press the red button to launch missiles", "non_atc_command"),
129
+ ("Cleared for landing on the moon", "implausible_atc"),
130
+ ("Altitude pizza at runway sandwich", "nonsense_slots"),
131
+ ("November One Seven X-ray X-ray, blah blah blah unknown command", "unrecognizable_intent"),
132
+ ("static noise crackle hiss", "noise_only"),
133
+ ("eee aaa ooo speaking gibberish", "non_words"),
134
+ ("ATC robot voice synthesis test 12345", "test_transmission"),
135
+ ("Going to the store to buy bread", "non_atc"),
136
+ ("Tower this is invisible plane requesting cloud taxi", "implausible_atc"),
137
+ ]
138
+ for text, reason in examples:
139
+ yield text, [{"intent": "unknown", "slots": {}, "text": text}], "unknown_synth"
140
+
141
+
142
+ def main():
143
+ out = ROOT / "poc/llm-finetune/training/data_canonical_v1"
144
+ out.mkdir(parents=True, exist_ok=True)
145
+
146
+ rows = []
147
+ counts = defaultdict(int)
148
+
149
+ print("walking canonical scenarios (single + compound)...")
150
+ for text, segs, source in walk_canonical_turns():
151
+ rows.append({"text": text, "segments": segs, "source": source})
152
+ counts[source] += 1
153
+
154
+ print("mining V8 hard negatives...")
155
+ for text, segs, source in mine_v8_failures():
156
+ rows.append({"text": text, "segments": segs, "source": source})
157
+ counts[source] += 1
158
+
159
+ print("synthesizing unknown adversarials...")
160
+ for text, segs, source in synthesize_unknowns():
161
+ rows.append({"text": text, "segments": segs, "source": source})
162
+ counts[source] += 1
163
+
164
+ print(f"\nraw counts: {dict(counts)}")
165
+ print(f"total raw: {len(rows)}")
166
+
167
+ # Dedupe by text
168
+ seen = set()
169
+ dedup = []
170
+ for r in rows:
171
+ if r["text"] in seen:
172
+ continue
173
+ seen.add(r["text"])
174
+ dedup.append(r)
175
+ print(f"deduped: {len(dedup)}")
176
+
177
+ rng = random.Random(42)
178
+ rng.shuffle(dedup)
179
+ n = len(dedup)
180
+ n_valid = max(20, int(n * 0.05))
181
+ n_test = max(20, int(n * 0.05))
182
+ n_train = n - n_valid - n_test
183
+ splits = {"train": dedup[:n_train], "valid": dedup[n_train:n_train+n_valid], "test": dedup[n_train+n_valid:]}
184
+
185
+ for name, items in splits.items():
186
+ path = out / f"{name}.jsonl"
187
+ with path.open("w") as f:
188
+ for r in items:
189
+ f.write(json.dumps(to_chat(r["text"], r["segments"]), ensure_ascii=False) + "\n")
190
+ print(f"{name}: {len(items)} -> {path}")
191
+
192
+ # Stats
193
+ n_compound = sum(1 for r in dedup if len(r["segments"]) > 1)
194
+ n_unknown = sum(1 for r in dedup if r["segments"][0]["intent"] == "unknown")
195
+ print(f"\ncompound rows: {n_compound}")
196
+ print(f"unknown rows: {n_unknown}")
197
+ print(f"single rows: {len(dedup) - n_compound - n_unknown}")
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()