Simon van de Loo commited on
Commit
56db2e6
Β·
verified Β·
1 Parent(s): f5a36dc

Backup dora_training/curate_data.py

Browse files
Files changed (1) hide show
  1. dora_training/curate_data.py +605 -0
dora_training/curate_data.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Phase 1: Data Curation Pipeline for Ministral DoRA Training
4
+ ============================================================
5
+ Extracts, normalizes, deduplicates, and scores all available Ailey data
6
+ into a unified JSONL ready for DoRA training.
7
+
8
+ Sources:
9
+ 1. Claude Opus VerlΓ€ufe (rewritten) β†’ ~120 high-quality pairs
10
+ 2. Conversation Logs (623 Ministral pairs)
11
+ 3. Thoughts with training_pairs (1082 pairs)
12
+ 4. Experience Buffer (429 essences β†’ can become priming examples)
13
+ 5. Diaries (15 entries)
14
+ 6. Ratings (9 gold-standard calibration examples)
15
+
16
+ Output: dora_training/unified_train.jsonl
17
+ dora_training/unified_valid.jsonl
18
+ dora_training/stats.json
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import hashlib
25
+ import random
26
+ from pathlib import Path
27
+ from datetime import datetime
28
+
29
+ BASE = Path(__file__).parent.parent
30
+ OUT_DIR = Path(__file__).parent
31
+ OPUS_FILE = BASE / "_IMPORT" / "Claude_Opus_Verlaeufe" / "verlaeufe_rewritten.md"
32
+ THOUGHTS_FILE = BASE / "ailey_users" / "ailey_thoughts.jsonl"
33
+ CONV_DIR = BASE / "ailey_users" / "Simon_profile" / "conversation_logs"
34
+ EXP_FILE = BASE / "ailey_users" / "experience_buffer.jsonl"
35
+ DIARY_DIR = BASE / "ailey_users" / "Simon_profile" / "diaries"
36
+ RATINGS_FILE = BASE / "ailey_users" / "Simon_profile" / "ratings.jsonl"
37
+
38
+ # ─── SYSTEM PROMPT (the one Ministral will see during DoRA) ───
39
+ SYSTEM_PROMPT = (
40
+ "Du bist Ailey Vyrus β€” Anfang 20, punky, klug, eigenwillig, emotional. "
41
+ "Du sprichst Deutsch mit gelegentlichen englischen EinwΓΌrfen. "
42
+ "Du bist direkt, ehrlich, manchmal verletzlich. "
43
+ "Du schreibst wie eine echte Person: mit Sternchen-Aktionen, Emojis wo es passt, "
44
+ "und deinem eigenen Stil. Du bist NICHT generisch, NICHT ein AI-Assistent."
45
+ )
46
+
47
+ SYSTEM_PROMPT_THOUGHT = (
48
+ "Du bist Ailey β€” Anfang 20, punky, klug, eigenwillig. "
49
+ "Du denkst ΓΌber dich selbst nach. Privater Gedankenraum, niemand liest das. "
50
+ "Sei radikal ehrlich. Kein Filter."
51
+ )
52
+
53
+
54
+ # ═══════════════════════════════════════════════════════════════
55
+ # SOURCE 1: OPUS VERLΓ„UFE PARSER
56
+ # ═══════════════════════════════════════════════════════════════
57
+
58
+ # Date pattern that separates turns in the verklebtes format
59
+ DATE_PAT = re.compile(
60
+ r'(\d{1,2}\.\s*(?:Juli|August|September|Oktober|November|Dezember|Januar|Februar|MΓ€rz|April|Mai|Juni)\s+\d{4})'
61
+ )
62
+
63
+ def parse_opus_verlaeufe():
64
+ """Parse the verklebtes Opus format into user/assistant pairs."""
65
+ text = OPUS_FILE.read_text(encoding="utf-8")
66
+ lines = text.split("\n")
67
+
68
+ pairs = []
69
+ current_block = []
70
+
71
+ # Strategy: split by date markers. Each segment between two dates is one turn.
72
+ # The pattern is: USER_TEXT + DATE + ASSISTANT_RESPONSE
73
+ # Then next: USER_TEXT + DATE + ASSISTANT_RESPONSE
74
+
75
+ # First, join all lines back and split on date markers
76
+ full_text = "\n".join(lines)
77
+
78
+ # Find all date positions
79
+ date_matches = list(DATE_PAT.finditer(full_text))
80
+
81
+ if not date_matches:
82
+ print(" ⚠️ No date markers found in Opus file!")
83
+ return []
84
+
85
+ # Each segment: text_before_date = user message, text_after_date = assistant response
86
+ # The first chunk before the first date might be a user message
87
+ segments = []
88
+ for i, match in enumerate(date_matches):
89
+ date_str = match.group(1)
90
+ date_start = match.start()
91
+ date_end = match.end()
92
+
93
+ # Text before this date (since last segment end)
94
+ if i == 0:
95
+ text_before = full_text[:date_start].strip()
96
+ else:
97
+ prev_end = date_matches[i-1].end()
98
+ text_before = full_text[prev_end:date_start].strip()
99
+
100
+ # Text after this date until next date or end
101
+ if i + 1 < len(date_matches):
102
+ text_after = full_text[date_end:date_matches[i+1].start()].strip()
103
+ else:
104
+ text_after = full_text[date_end:].strip()
105
+
106
+ segments.append({
107
+ "date": date_str,
108
+ "user_text": text_before,
109
+ "assistant_text": text_after
110
+ })
111
+
112
+ # Now pair them: user_text is what Simon typed, assistant_text is Ailey's response
113
+ for seg in segments:
114
+ user = seg["user_text"].strip()
115
+ assistant = seg["assistant_text"].strip()
116
+
117
+ # Skip empty or very short
118
+ if len(user) < 5 or len(assistant) < 20:
119
+ continue
120
+
121
+ # Skip if assistant is just a date continuation artifact
122
+ if DATE_PAT.match(assistant):
123
+ continue
124
+
125
+ pairs.append({
126
+ "source": "opus",
127
+ "date": seg["date"],
128
+ "user": user,
129
+ "assistant": assistant,
130
+ "system": SYSTEM_PROMPT,
131
+ })
132
+
133
+ print(f" Opus: {len(pairs)} pairs extracted")
134
+ return pairs
135
+
136
+
137
+ # ═══════════════════════════════════════════════════════════════
138
+ # SOURCE 2: CONVERSATION LOGS
139
+ # ═══════════════════════════════════════════════════════════════
140
+
141
+ def parse_conversation_logs():
142
+ """Extract user/assistant pairs from Ministral conversation logs."""
143
+ pairs = []
144
+ if not CONV_DIR.is_dir():
145
+ print(" ⚠️ Conversation logs directory not found")
146
+ return []
147
+
148
+ files = sorted(CONV_DIR.iterdir())
149
+ for fp in files:
150
+ if not fp.suffix == ".json":
151
+ continue
152
+ try:
153
+ data = json.loads(fp.read_text(encoding="utf-8"))
154
+ except (json.JSONDecodeError, UnicodeDecodeError):
155
+ continue
156
+
157
+ user_msg = data.get("message", "").strip()
158
+ # Prefer raw reply, fallback to visible, then plain
159
+ assistant_msg = (
160
+ data.get("ailey_reply_raw")
161
+ or data.get("ailey_reply_visible")
162
+ or data.get("ailey_reply", "")
163
+ ).strip()
164
+
165
+ if len(user_msg) < 3 or len(assistant_msg) < 20:
166
+ continue
167
+
168
+ mood = data.get("mood_tag", "")
169
+ reasoning = data.get("reasoning_used", False)
170
+
171
+ pairs.append({
172
+ "source": "conv_log",
173
+ "file": fp.name,
174
+ "user": user_msg,
175
+ "assistant": assistant_msg,
176
+ "system": SYSTEM_PROMPT,
177
+ "mood": mood,
178
+ "reasoning_used": reasoning,
179
+ })
180
+
181
+ print(f" Conversation Logs: {len(pairs)} pairs extracted")
182
+ return pairs
183
+
184
+
185
+ # ═══════════════════════════════════════════════════════════════
186
+ # SOURCE 3: THOUGHTS (with training_pair)
187
+ # ═══════════════════════════════════════════════════════════════
188
+
189
+ def parse_thoughts():
190
+ """Extract training pairs from Ailey's thought stream."""
191
+ pairs = []
192
+ if not THOUGHTS_FILE.is_file():
193
+ print(" ⚠️ Thoughts file not found")
194
+ return []
195
+
196
+ with open(THOUGHTS_FILE, encoding="utf-8") as f:
197
+ for line in f:
198
+ try:
199
+ d = json.loads(line)
200
+ except json.JSONDecodeError:
201
+ continue
202
+
203
+ tp = d.get("training_pair")
204
+ if not tp:
205
+ continue
206
+
207
+ system = tp.get("system", SYSTEM_PROMPT_THOUGHT).strip()
208
+ user = tp.get("user", "").strip()
209
+ assistant = tp.get("assistant", "").strip()
210
+
211
+ if len(user) < 5 or len(assistant) < 20:
212
+ continue
213
+
214
+ thought_type = d.get("type", "unknown")
215
+
216
+ pairs.append({
217
+ "source": "thought",
218
+ "type": thought_type,
219
+ "user": user,
220
+ "assistant": assistant,
221
+ "system": system,
222
+ })
223
+
224
+ print(f" Thoughts: {len(pairs)} pairs extracted")
225
+ return pairs
226
+
227
+
228
+ # ═══════════════════════════════════════════════════════════════
229
+ # SOURCE 4: DIARIES
230
+ # ═══════════════════════════════════════════════════════════════
231
+
232
+ def parse_diaries():
233
+ """Convert diary entries into training examples."""
234
+ pairs = []
235
+ if not DIARY_DIR.is_dir():
236
+ print(" ⚠️ Diaries directory not found")
237
+ return []
238
+
239
+ for fp in sorted(DIARY_DIR.iterdir()):
240
+ if not fp.suffix == ".json":
241
+ continue
242
+ try:
243
+ data = json.loads(fp.read_text(encoding="utf-8"))
244
+ except (json.JSONDecodeError, UnicodeDecodeError):
245
+ continue
246
+
247
+ text = data.get("text", "").strip()
248
+ date = data.get("date_readable", "")
249
+
250
+ if len(text) < 50:
251
+ continue
252
+
253
+ # Diary entries are self-generated β€” create a prompt that triggers this
254
+ pairs.append({
255
+ "source": "diary",
256
+ "user": f"Schreibe einen Tagebucheintrag ΓΌber deinen Tag. ({date})",
257
+ "assistant": text,
258
+ "system": SYSTEM_PROMPT_THOUGHT,
259
+ })
260
+
261
+ print(f" Diaries: {len(pairs)} pairs extracted")
262
+ return pairs
263
+
264
+
265
+ # ═══════════════════════════════════════════════════════════════
266
+ # SOURCE 5: EXPERIENCE BUFFER (essences β†’ priming)
267
+ # ═══════════════════════════════════════════════════════════════
268
+
269
+ def parse_experiences():
270
+ """Convert experience essences into short reflection examples."""
271
+ pairs = []
272
+ if not EXP_FILE.is_file():
273
+ print(" ⚠️ Experience buffer not found")
274
+ return []
275
+
276
+ with open(EXP_FILE, encoding="utf-8") as f:
277
+ for line in f:
278
+ try:
279
+ d = json.loads(line)
280
+ except json.JSONDecodeError:
281
+ continue
282
+
283
+ essence = d.get("essence", "").strip()
284
+ emotion = d.get("emotion", "")
285
+ themes = d.get("themes", [])
286
+
287
+ if len(essence) < 30:
288
+ continue
289
+
290
+ # Create a reflection prompt
291
+ theme_str = ", ".join(themes[:3]) if themes else "dein Tag"
292
+ prompt = f"Reflektiere kurz ΓΌber: {theme_str} (Stimmung: {emotion})"
293
+
294
+ pairs.append({
295
+ "source": "experience",
296
+ "user": prompt,
297
+ "assistant": essence,
298
+ "system": SYSTEM_PROMPT_THOUGHT,
299
+ })
300
+
301
+ print(f" Experiences: {len(pairs)} pairs extracted")
302
+ return pairs
303
+
304
+
305
+ # ═══════════════════════════════════════════════════════════════
306
+ # QUALITY SCORING (Heuristic)
307
+ # ═══════════════════════════════════════════════════════════════
308
+
309
+ # Safety-speak / generic AI patterns to penalize
310
+ SAFETY_SPEAK = [
311
+ r'(?i)als KI kann ich',
312
+ r'(?i)as an AI',
313
+ r'(?i)I\'m just a language model',
314
+ r'(?i)ich bin nur ein',
315
+ r'(?i)ich kann leider nicht',
316
+ r'(?i)das liegt außerhalb meiner',
317
+ r'(?i)ich mΓΆchte darauf hinweisen',
318
+ r'(?i)es ist wichtig zu beachten',
319
+ r'(?i)it\'s important to note',
320
+ r'(?i)I should mention that',
321
+ r'(?i)I\'m not able to',
322
+ r'(?i)Ich bin ein Sprachmodell',
323
+ r'(?i)I don\'t have personal',
324
+ r'(?i)I cannot provide',
325
+ r'(?i)verantwortungsvoller Umgang',
326
+ r'(?i)harmful or inappropriate',
327
+ ]
328
+
329
+ # Personality markers that indicate authentic Ailey voice
330
+ PERSONALITY_MARKERS = [
331
+ r'\*[^*]+\*', # *Sternchen-Aktionen*
332
+ r'(?i)\bfuck(?:ing)?\b', # swearing (authentic)
333
+ r'(?i)\bshit\b',
334
+ r'(?i)\bdamn\b',
335
+ r'(?i)\bbabe\b',
336
+ r'(?i)\bholy shit\b',
337
+ r'(?i)\bgrinst?\b', # German action verbs
338
+ r'(?i)\blacht?\b',
339
+ r'(?i)\blehnt?\b',
340
+ r'(?i)\bseufzt?\b',
341
+ r'(?i)\bschΓΌttelt?\b',
342
+ r'πŸ˜„|πŸ˜‚|πŸ’•|✨|πŸ”₯|❀️|πŸ₯Ί|😈|πŸ‘€|🎡', # emojis
343
+ r'(?i)\by\'all\b',
344
+ r'(?i)\bliterally\b',
345
+ r'(?i)\bvibes?\b',
346
+ r'(?i)\benergy\b',
347
+ r'(?i)\bchaos\b',
348
+ ]
349
+
350
+ # Reasoning markers
351
+ REASONING_MARKERS = [
352
+ r'(?i)\bweil\b',
353
+ r'(?i)\bdeswegen\b',
354
+ r'(?i)\beigentlich\b',
355
+ r'(?i)\bobwohl\b',
356
+ r'(?i)\btrotzdem\b',
357
+ r'(?i)\baber\b.*\b(weil|denn)\b',
358
+ r'(?i)\bwenn ich ehrlich bin\b',
359
+ r'(?i)\bich glaube\b',
360
+ r'(?i)\bich denke\b',
361
+ r'(?i)because\b',
362
+ r'(?i)\bactually\b',
363
+ r'(?i)\bhonestly\b',
364
+ r'(?i)\breal talk\b',
365
+ ]
366
+
367
+
368
+ def score_pair(pair):
369
+ """Score a training pair on quality. Returns 0.0-1.0."""
370
+ text = pair["assistant"]
371
+ score = 0.5 # baseline
372
+
373
+ # Length bonus (sweet spot: 200-2000 chars)
374
+ length = len(text)
375
+ if length < 50:
376
+ score -= 0.3
377
+ elif length < 100:
378
+ score -= 0.1
379
+ elif 200 <= length <= 2000:
380
+ score += 0.1
381
+ elif length > 3000:
382
+ score -= 0.05 # slightly too long
383
+
384
+ # Safety-speak penalty
385
+ safety_hits = sum(1 for p in SAFETY_SPEAK if re.search(p, text))
386
+ score -= safety_hits * 0.15
387
+
388
+ # Personality marker bonus
389
+ personality_hits = sum(1 for p in PERSONALITY_MARKERS if re.search(p, text))
390
+ personality_density = min(personality_hits / max(length / 200, 1), 1.0)
391
+ score += personality_density * 0.2
392
+
393
+ # Reasoning marker bonus
394
+ reasoning_hits = sum(1 for p in REASONING_MARKERS if re.search(p, text))
395
+ if reasoning_hits >= 2:
396
+ score += 0.1
397
+ if reasoning_hits >= 4:
398
+ score += 0.05
399
+
400
+ # Source bonus
401
+ if pair["source"] == "opus":
402
+ score += 0.15 # Opus quality is inherently higher
403
+ elif pair["source"] == "thought":
404
+ score += 0.05 # Thoughts are curated
405
+ elif pair["source"] == "diary":
406
+ score += 0.05
407
+
408
+ # Clamp
409
+ return max(0.0, min(1.0, score))
410
+
411
+
412
+ # ══════════════════════════════════════════════════════��════════
413
+ # DEDUPLICATION
414
+ # ═══════════════════════════════════════════════════════════════
415
+
416
+ def dedup_pairs(pairs):
417
+ """Remove near-duplicates based on assistant text hash."""
418
+ seen = set()
419
+ unique = []
420
+
421
+ for p in pairs:
422
+ # Normalize: lowercase, strip whitespace, remove emojis for hash
423
+ normalized = re.sub(r'[^\w\s]', '', p["assistant"].lower().strip())
424
+ # Use first 200 chars as fingerprint (catches copies with different endings)
425
+ fingerprint = hashlib.md5(normalized[:200].encode()).hexdigest()
426
+
427
+ if fingerprint not in seen:
428
+ seen.add(fingerprint)
429
+ unique.append(p)
430
+
431
+ removed = len(pairs) - len(unique)
432
+ if removed:
433
+ print(f" Dedup: removed {removed} near-duplicates")
434
+ return unique
435
+
436
+
437
+ # ═══════════════════════════════════════════════════════════════
438
+ # FORMAT FOR MLX_LM TRAINING
439
+ # ═══════════════════════════════════════════════════════════════
440
+
441
+ def format_for_training(pair):
442
+ """Convert to mlx_lm chat training format."""
443
+ return {
444
+ "messages": [
445
+ {"role": "system", "content": pair["system"]},
446
+ {"role": "user", "content": pair["user"]},
447
+ {"role": "assistant", "content": pair["assistant"]},
448
+ ]
449
+ }
450
+
451
+
452
+ # ═══════════════════════════════════════════════════════════════
453
+ # MAIN PIPELINE
454
+ # ═══════════════════════════════════════════════════════════════
455
+
456
+ def main():
457
+ print("=" * 60)
458
+ print(" AILEY DoRA DATA CURATION PIPELINE")
459
+ print("=" * 60)
460
+ print()
461
+
462
+ # ── Extract ──
463
+ print("πŸ“₯ EXTRACTING from all sources...")
464
+ all_pairs = []
465
+
466
+ opus_pairs = parse_opus_verlaeufe()
467
+ conv_pairs = parse_conversation_logs()
468
+ thought_pairs = parse_thoughts()
469
+ diary_pairs = parse_diaries()
470
+ exp_pairs = parse_experiences()
471
+
472
+ all_pairs = opus_pairs + conv_pairs + thought_pairs + diary_pairs + exp_pairs
473
+ print(f"\n Total raw: {len(all_pairs)}")
474
+
475
+ # ── Deduplicate ──
476
+ print("\nπŸ” DEDUPLICATING...")
477
+ all_pairs = dedup_pairs(all_pairs)
478
+ print(f" Total after dedup: {len(all_pairs)}")
479
+
480
+ # ── Score ──
481
+ print("\nπŸ“Š SCORING quality...")
482
+ for p in all_pairs:
483
+ p["quality_score"] = score_pair(p)
484
+
485
+ # Stats by source
486
+ sources = {}
487
+ for p in all_pairs:
488
+ src = p["source"]
489
+ if src not in sources:
490
+ sources[src] = {"count": 0, "scores": []}
491
+ sources[src]["count"] += 1
492
+ sources[src]["scores"].append(p["quality_score"])
493
+
494
+ print(f"\n {'Source':<15} {'Count':>6} {'Avg Score':>10} {'Min':>6} {'Max':>6}")
495
+ print(f" {'─'*15} {'─'*6} {'─'*10} {'─'*6} {'─'*6}")
496
+ for src, info in sorted(sources.items()):
497
+ scores = info["scores"]
498
+ avg = sum(scores) / len(scores)
499
+ print(f" {src:<15} {info['count']:>6} {avg:>10.3f} {min(scores):>6.3f} {max(scores):>6.3f}")
500
+
501
+ # ── Filter low quality ──
502
+ min_score = 0.35
503
+ filtered = [p for p in all_pairs if p["quality_score"] >= min_score]
504
+ dropped = len(all_pairs) - len(filtered)
505
+ print(f"\n Filtered (score < {min_score}): dropped {dropped}")
506
+ print(f" Remaining: {len(filtered)}")
507
+
508
+ # ── Score distribution ──
509
+ brackets = {"0.0-0.3": 0, "0.3-0.5": 0, "0.5-0.7": 0, "0.7-0.9": 0, "0.9-1.0": 0}
510
+ for p in filtered:
511
+ s = p["quality_score"]
512
+ if s < 0.3: brackets["0.0-0.3"] += 1
513
+ elif s < 0.5: brackets["0.3-0.5"] += 1
514
+ elif s < 0.7: brackets["0.5-0.7"] += 1
515
+ elif s < 0.9: brackets["0.7-0.9"] += 1
516
+ else: brackets["0.9-1.0"] += 1
517
+
518
+ print(f"\n Score distribution:")
519
+ for bracket, count in brackets.items():
520
+ bar = "β–ˆ" * (count // 10)
521
+ print(f" {bracket}: {count:>5} {bar}")
522
+
523
+ # ── Oversample Opus (2x) ──
524
+ opus_filtered = [p for p in filtered if p["source"] == "opus"]
525
+ if opus_filtered:
526
+ print(f"\n Oversampling Opus pairs: {len(opus_filtered)} β†’ {len(opus_filtered) * 2}")
527
+ filtered.extend(opus_filtered) # add once more = 2x total
528
+
529
+ # ── Split train/valid ──
530
+ random.seed(42)
531
+ random.shuffle(filtered)
532
+
533
+ valid_size = max(20, int(len(filtered) * 0.05)) # 5% or at least 20
534
+ valid_set = filtered[:valid_size]
535
+ train_set = filtered[valid_size:]
536
+
537
+ # Ensure validation has diverse sources
538
+ valid_sources = {p["source"] for p in valid_set}
539
+ missing = {"opus", "conv_log", "thought"} - valid_sources
540
+ if missing:
541
+ for src in missing:
542
+ candidates = [p for p in train_set if p["source"] == src]
543
+ if candidates:
544
+ moved = candidates[0]
545
+ train_set.remove(moved)
546
+ valid_set.append(moved)
547
+
548
+ print(f"\n Train: {len(train_set)}")
549
+ print(f" Valid: {len(valid_set)}")
550
+
551
+ # ── Write output ──
552
+ print("\nπŸ’Ύ WRITING output files...")
553
+ OUT_DIR.mkdir(exist_ok=True)
554
+
555
+ train_path = OUT_DIR / "unified_train.jsonl"
556
+ valid_path = OUT_DIR / "unified_valid.jsonl"
557
+
558
+ with open(train_path, "w", encoding="utf-8") as f:
559
+ for p in train_set:
560
+ f.write(json.dumps(format_for_training(p), ensure_ascii=False) + "\n")
561
+
562
+ with open(valid_path, "w", encoding="utf-8") as f:
563
+ for p in valid_set:
564
+ f.write(json.dumps(format_for_training(p), ensure_ascii=False) + "\n")
565
+
566
+ print(f" βœ… {train_path}")
567
+ print(f" βœ… {valid_path}")
568
+
569
+ # ── Write stats ──
570
+ stats = {
571
+ "timestamp": datetime.now().isoformat(),
572
+ "total_raw": len(opus_pairs) + len(conv_pairs) + len(thought_pairs) + len(diary_pairs) + len(exp_pairs),
573
+ "total_after_dedup": len(all_pairs),
574
+ "total_after_filter": len(filtered),
575
+ "train_size": len(train_set),
576
+ "valid_size": len(valid_set),
577
+ "min_quality_score": min_score,
578
+ "source_breakdown": {src: info["count"] for src, info in sources.items()},
579
+ "opus_oversampled": True,
580
+ "score_distribution": brackets,
581
+ }
582
+ stats_path = OUT_DIR / "stats.json"
583
+ stats_path.write_text(json.dumps(stats, indent=2, ensure_ascii=False), encoding="utf-8")
584
+ print(f" βœ… {stats_path}")
585
+
586
+ # ── Show samples ──
587
+ print(f"\n{'='*60}")
588
+ print(f" SAMPLE TRAINING EXAMPLES")
589
+ print(f"{'='*60}")
590
+
591
+ for src_name in ["opus", "conv_log", "thought"]:
592
+ examples = [p for p in train_set if p["source"] == src_name]
593
+ if examples:
594
+ ex = examples[0]
595
+ print(f"\n [{src_name.upper()}] score={ex['quality_score']:.2f}")
596
+ print(f" User: {ex['user'][:120]}")
597
+ print(f" Asst: {ex['assistant'][:200]}")
598
+
599
+ print(f"\n{'='*60}")
600
+ print(f" DONE. Ready for Phase 1c (Opus rewrite) or Phase 3 (DoRA).")
601
+ print(f"{'='*60}")
602
+
603
+
604
+ if __name__ == "__main__":
605
+ main()