File size: 32,774 Bytes
56d97c6
 
99575fe
93229a0
56d97c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93229a0
 
56d97c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
56d97c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5747d32
 
93229a0
 
 
 
 
 
 
 
5747d32
 
 
 
93229a0
5747d32
 
93229a0
 
5747d32
 
 
 
 
 
 
 
 
 
93229a0
 
 
5747d32
 
93229a0
 
5747d32
 
 
 
93229a0
 
5747d32
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5747d32
56d97c6
 
 
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
56d97c6
93229a0
 
 
 
 
 
 
56d97c6
 
 
 
 
 
 
 
 
 
 
a13c429
56d97c6
 
 
 
93229a0
 
 
 
56d97c6
 
a13c429
 
56d97c6
 
93229a0
a13c429
 
 
93229a0
 
56d97c6
 
 
 
93229a0
56d97c6
 
 
 
5747d32
93229a0
56d97c6
 
a13c429
 
 
 
 
 
 
 
93229a0
 
 
 
 
a13c429
 
56d97c6
 
 
 
a13c429
56d97c6
 
93229a0
56d97c6
a13c429
 
93229a0
 
 
 
 
a13c429
93229a0
 
a13c429
93229a0
 
a13c429
93229a0
 
 
 
56d97c6
 
a13c429
56d97c6
 
 
 
 
 
 
 
 
93229a0
56d97c6
 
 
 
 
 
 
 
 
93229a0
 
a13c429
56d97c6
 
 
 
 
a13c429
56d97c6
 
 
93229a0
56d97c6
a13c429
 
 
 
 
56d97c6
93229a0
 
a13c429
 
 
 
 
93229a0
 
 
56d97c6
 
 
 
93229a0
56d97c6
 
 
 
93229a0
 
56d97c6
 
 
 
93229a0
 
 
56d97c6
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d97c6
a13c429
 
56d97c6
 
 
 
 
 
 
 
 
 
 
 
93229a0
56d97c6
 
 
 
a13c429
56d97c6
 
 
 
5747d32
 
93229a0
 
5747d32
93229a0
 
 
 
5747d32
 
56d97c6
 
 
93229a0
56d97c6
 
 
 
93229a0
56d97c6
 
a13c429
 
 
 
 
 
 
56d97c6
 
 
 
a13c429
56d97c6
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a13c429
93229a0
a13c429
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a13c429
 
 
 
 
93229a0
a13c429
93229a0
 
 
a13c429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d97c6
a13c429
56d97c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93229a0
 
 
 
 
56d97c6
a13c429
56d97c6
93229a0
a13c429
 
 
 
 
93229a0
 
56d97c6
 
 
 
 
a13c429
56d97c6
a13c429
 
56d97c6
 
 
a13c429
56d97c6
 
 
 
 
 
 
 
a13c429
56d97c6
93229a0
 
a13c429
93229a0
a13c429
93229a0
a13c429
93229a0
a13c429
 
 
93229a0
a13c429
 
 
 
5747d32
56d97c6
93229a0
 
 
56d97c6
93229a0
a13c429
 
56d97c6
 
 
a13c429
56d97c6
 
 
 
 
 
 
 
 
 
 
 
93229a0
56d97c6
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
56d97c6
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
56d97c6
93229a0
56d97c6
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d97c6
93229a0
56d97c6
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
56d97c6
93229a0
 
 
 
 
 
 
 
 
5747d32
56d97c6
 
 
 
 
 
 
 
93229a0
56d97c6
 
93229a0
56d97c6
 
 
 
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d97c6
 
 
 
93229a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f7fa2d
 
 
 
 
 
93229a0
9f7fa2d
93229a0
9f7fa2d
 
 
 
 
 
 
 
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
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
# Dataset Collection Guidance

**Applies to:** `scikit-plots/ai-assistant-contributions` (HuggingFace Dataset)
**Version:** 2.0
**Maintained by:** scikit-plots maintainers

---

## 1. Overview

The AI-assistant widget collects fine-tuning data through **two independent paths**
that can produce records for the same (conversation, answer) pair:

| Path | Endpoint | Folder | Trigger |
|------|----------|--------|---------|
| **Individual feedback** | `POST /v1/feedback` | `feedback/` | User clicks a rating button immediately after each answer |
| **Whole-conversation contribution** | `POST /v1/contribute` | `contributions/` | User clicks the **Contribute** button in the share sheet (GDPR-gated) |

When both paths are active (`FEEDBACK_PERSIST_ENABLED=true` **and** the user
later clicks Contribute), the **same Q&A pair is stored twice**.  Training on
duplicated examples artificially amplifies those examples β€” the dominant
contributor problem β€” so duplicates **must be resolved before any training run**.

---

## 2. Deduplication Key Contract

Every JSONL record stored in the dataset carries two mandatory server-assigned
provenance fields:

| Field | Type | Value | Set by |
|-------|------|-------|--------|
| `_source` | `"feedback"` \| `"contribution"` | Provenance tag | Server |
| `_dedup_key` | `string` | `"{conversationId}:{answerIndex}"` | Server |

### Key format

```
_dedup_key = f"{conversationId}:{answerIndex}"
```

- **`conversationId`** β€” stable per-page-load UUID (`_sessionId` in the JS
  widget, set once at module load and never re-generated).  Sent as
  `detail.conversationId` in the feedback POST and as `payload.sessionId` in the
  contribution POST.  Both endpoints use the *same* UUID so equality across
  folders is sufficient to detect cross-source duplicates.

- **`answerIndex`** β€” zero-based integer position of the answer within the
  conversation transcript.

> **Invariant:** Within a single conversation, `(conversationId, answerIndex)`
> is unique.  Across conversations, `conversationId` alone is unique (UUID).
> Therefore `_dedup_key` is globally unique for a given answer position within
> a given conversation session.

### v2 direct foreign key (preferred join key)

Since schema version 2, contribution records carry an additional
**direct foreign key** alongside `_dedup_key`:

| Field | Type | Description |
|-------|------|-------------|
| `feedbackId` | `string \| null` | `feedbackId` (= `sessionId`) of the matching `feedback/` record, when the user individually rated this answer before clicking Contribute. `null` when they contributed without ever rating the specific answer. |

This is a **1-to-1 join key** between `contributions/` rows and `feedback/` rows β€”
use it for precise cross-source joins instead of the coarser `_dedup_key`.

---

## 3. Priority Rule

When two records share the same `_dedup_key`, **always keep the
`contribution` record and discard the `feedback` record**.

Rationale:

- The contribution path is gated behind explicit GDPR consent.  It represents
  a deliberate, end-of-session review of the user's ratings and is the
  higher-quality signal.
- The feedback path fires immediately on button click (keepalive, fire-and-
  forget) β€” it may capture a transient or misclicked rating that the user
  later reconsiders before clicking Contribute.

```
Priority:  contribution  >  feedback
```

### Retraction tombstones

When a user edits a previously submitted rating, the browser sends a
**retraction tombstone** record before the new rating.  Tombstones are
stored in the `feedback/` folder alongside normal rating records.

Since schema version 2 the canonical field name for the superseded record's
identifier is `prevFeedbackId` (previously `prevSessionId`).  Both field names
are handled by `normalize_record` in `_dataset_schema.py` for backward
compatibility.

| Field | Value |
|-------|-------|
| `action` | `"retract"` |
| `prevFeedbackId` | `feedbackId` of the original rating record (v2) |
| `_dedup_key` | identical to the original record's `_dedup_key` |
| `_ts` | server-write timestamp β€” always later than the original |
| `ratingValue` | `null` (tombstones carry no rating) |
| `editCount` | `null` (not applicable to a tombstone) |

Tombstones participate in the LWW loop inside `deduplicate()` because their
`_ts` is later than the original record's, ensuring the original rating is
not emitted.  They are then **unconditionally removed from the clean output**:
a tombstone is never a valid training example.

**Normal terminal state** (edit completed, all three records share the same
`_dedup_key`):

```
_ts 100  action="rate"    ratingValue=+1  prevFeedbackId=null     editCount=0
_ts 200  action="retract" ratingValue=null prevFeedbackId=<id-A>  editCount=null
_ts 201  action="rate"    ratingValue=-1  prevFeedbackId=<id-A>   editCount=1
```

LWW selects `_ts 201` (most recent `rate` action). No tombstone in output. βœ“

**Degenerate case** (tombstone wins β€” follow-up rating never reached the
server, e.g. network failure after the retraction was sent):

```
_ts 100  action="rate"    ratingValue=+1
_ts 200  action="retract" ratingValue=null  <- LWW winner
```

Net result: the original +1 was explicitly retracted, so **no record is
emitted** for this key.  Training data is never contaminated.

### Supersession chain resolution (v2)

Beyond LWW, `feedbackId` + `prevFeedbackId` form a walkable edit-chain
per `(conversationId, answerIndex)`:

```
rating A: feedbackId="id-A"  prevFeedbackId=null    editCount=0  <- first rating
rating B: feedbackId="id-B"  prevFeedbackId="id-A"  editCount=1  <- edited
rating C: feedbackId="id-C"  prevFeedbackId="id-B"  editCount=2  <- edited again
```

`editCount` gives the chain length without walking it.  The record
with the highest `editCount` and `action="rate"` is the current active rating
for that `(conversationId, answerIndex)`.  `deduplicate_dataset.py` resolves
this automatically via LWW on `_ts`.

---

## 4. Dataset Folder Structure

```
scikit-plots/ai-assistant-contributions/
|-- contributions/
|   `-- {unix_ms}.jsonl      # 1 file per /v1/contribute POST
|                             # Each line = 1 Q&A record (canonical schema v2)
|                             # Key fields: conversationId, feedbackId (FK->feedback/),
|                             #             answerIndex, query, answer,
|                             #             ratingValue, ratingSlug, ratingTitle, ratingMode,
|                             #             prevFeedbackId, editCount, message, ts,
|                             #             model (8-key), page, consentVersion (null),
|                             #             _source="contribution", _dedup_key, _ts
`-- feedback/
    `-- {unix_ms}.jsonl      # 1 file per /v1/feedback POST (when
                              #   FEEDBACK_PERSIST_ENABLED=true)
                              # Each file = 1 record (1 rating or 1 retract tombstone)
                              # Key fields: conversationId, feedbackId,
                              #             action, answerIndex,
                              #             ratingValue, ratingSlug, ratingTitle, ratingMode,
                              #             prevFeedbackId, editCount, message, query, answer,
                              #             model (8-key), page, consentVersion (null),
                              #             _source="feedback", _dedup_key, _ts
```

---

## 5. Canonical Deduplication Script

Run this script **before every training job** to produce a clean, deduplicated
NDJSON file.  It reads all JSONL files from both folders, deduplicates by
`_dedup_key` applying the priority rule, and writes one record per unique key.

```python
r"""
deduplicate_dataset.py
======================
Canonical deduplication script for scikit-plots/ai-assistant-contributions.

Supports schema versions 1 (legacy) and 2 (current).  Records are normalised
to the canonical v2 schema by ``_dataset_schema.normalize_record`` before
deduplication so callers can always expect the full field set.

Usage
-----
    python deduplicate_dataset.py \
        --repo-id scikit-plots/ai-assistant-contributions \
        --output  clean_dataset.jsonl

    # Use a local pre-downloaded snapshot (faster on re-runs):
    python deduplicate_dataset.py \
        --repo-id scikit-plots/ai-assistant-contributions \
        --local-dir /tmp/ai-contributions-snapshot \
        --output clean_dataset.jsonl

Requirements
------------
    huggingface_hub>=0.23,<2
    (optional) hf_transfer for faster downloads
    (optional) _dataset_schema.py (from _hf_spaces_proxy/) for normalization

Notes
-----
* Priority rule: "contribution" beats "feedback" for the same _dedup_key.
* Retraction tombstones (action="retract") are always excluded from the
  clean output even if they win the LWW race.
* Script is idempotent: re-running produces the same output for the same
  dataset state.
* Output records are written with ``sort_keys=True``, so every record's
  keys (including nested objects) appear in a fixed alphabetical order in
  clean_dataset.jsonl.
* Progress and statistics are emitted via the module ``logging`` logger.
  INFO-level records route to stdout; WARNING and ERROR records route to
  stderr β€” preserving the previous ``print`` /
  ``print(..., file=sys.stderr)`` split so that callers capturing stdout
  see only the NDJSON data.
* When _dataset_schema is importable, records are normalised from v1 to v2
  schema automatically (legacy _sessionId/_page/_model fields mapped to
  conversationId/page/model; editCount/feedbackId/prevFeedbackId back-filled).
  When _dataset_schema is not importable (standalone usage), records are used
  as-is with a warning.
"""  # noqa: D205, D400

from __future__ import annotations

import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# Optional: normalize records from v1 to v2 schema when _dataset_schema is
# available alongside this script (standard _hf_spaces_proxy/ deployment).
# Falls back to identity function with a warning for standalone usage.
try:
    from _dataset_schema import normalize_record as _normalize_record

    _SCHEMA_AVAILABLE = True
except ImportError:

    def _normalize_record(raw: dict) -> dict:
        return raw

    _SCHEMA_AVAILABLE = False


# Priority order: lower index = higher priority.
_SOURCE_PRIORITY: dict[str, int] = {
    "contribution": 0,
    "feedback": 1,
}
_DEFAULT_PRIORITY = 99


def _priority(record: dict) -> int:
    return _SOURCE_PRIORITY.get(record.get("_source", ""), _DEFAULT_PRIORITY)


def load_all_records(local_dir: Path) -> list[dict]:
    """Read every *.jsonl file under local_dir into a flat list.

    Parameters
    ----------
    local_dir : pathlib.Path
        Root of the locally downloaded dataset snapshot.

    Returns
    -------
    list[dict]
        All JSON-decoded records, normalised to canonical v2 schema when
        ``_dataset_schema`` is importable.  Malformed lines are skipped with
        a WARNING-level log record.
    """
    records: list[dict] = []
    for jsonl_path in sorted(local_dir.rglob("*.jsonl")):
        with jsonl_path.open(encoding="utf-8") as fh:
            for lineno, line in enumerate(fh, 1):
                line = line.strip()  # noqa: PLW2901
                if not line:
                    continue
                try:
                    raw = json.loads(line)
                except json.JSONDecodeError as exc:
                    logger.warning(
                        "Skipping malformed JSON in %s:%d: %s",
                        jsonl_path,
                        lineno,
                        exc,
                    )
                    continue
                if not isinstance(raw, dict):
                    logger.warning(
                        "%s:%d: expected JSON object, got %s -- skipped",
                        jsonl_path,
                        lineno,
                        type(raw).__name__,
                    )
                    continue
                records.append(_normalize_record(raw))
    return records


def deduplicate(records: list[dict]) -> list[dict]:
    """Deduplicate records by _dedup_key applying the priority rule.

    Parameters
    ----------
    records : list[dict]
        All raw records from both contributions/ and feedback/ folders,
        already normalised to v2 schema by load_all_records.

    Returns
    -------
    list[dict]
        One record per unique _dedup_key.  Records that have no
        _dedup_key (legacy, pre-v1.0 records) are retained unchanged.
        Retraction tombstones are excluded from the output.

    Notes
    -----
    Priority rule
        For the same _dedup_key, the record with the lowest
        _SOURCE_PRIORITY value is kept.  Ties (same source) are broken by
        server-write timestamp (_ts), keeping the most recent.  Deterministic:
        given the same input, the output is always the same.

    Retraction tombstones
        action="retract" records are still used during the LWW loop
        because their later _ts must suppress an earlier rate record
        (correct behaviour).  They are removed in the post-loop filter so they
        cannot leak into clean_dataset.jsonl.

        Degenerate case -- orphaned tombstone wins: silently discarded.
        Net effect: the original rating was explicitly retracted, so no record
        is emitted for that key -- correct for training data quality.

    feedbackId cross-source linkage (v2)
        When both a feedback/ record and a contributions/ record exist
        for the same _dedup_key, the contribution record's feedbackId
        field points directly to the feedback record's feedbackId (1-to-1
        FK).  The winning contribution record therefore carries the complete
        provenance chain without any additional join.
    """
    keyed: dict[str, dict] = {}  # _dedup_key -> winning record
    no_key: list[dict] = []  # legacy records without _dedup_key

    for rec in records:
        dk = rec.get("_dedup_key")
        if dk is None:
            no_key.append(rec)
            continue

        existing = keyed.get(dk)
        if existing is None:
            keyed[dk] = rec
            continue

        # Compare source priorities; lower = better (contribution > feedback).
        new_pri = _priority(rec)
        old_pri = _priority(existing)
        if new_pri < old_pri:
            keyed[dk] = rec
        elif new_pri == old_pri:  # noqa: SIM102
            # Same source: keep the most recently written record (_ts).
            if rec.get("_ts", 0) > existing.get("_ts", 0):
                keyed[dk] = rec

    # Post-loop: discard retraction tombstones from the winning set.
    #
    # Scenario A (normal edit): user rates +1 (_ts=100), edits (tombstone at
    # _ts=200), then rates -1 (_ts=201).  LWW selects -1.  No tombstone. OK
    #
    # Scenario B (orphaned tombstone): +1 at _ts=100, tombstone at _ts=200,
    # but follow-up -1 never reached the server.  LWW selects the tombstone.
    # Without this filter, action="retract" with ratingValue=null would corrupt
    # training.  Filter silently drops it. OK
    clean_keyed = [r for r in keyed.values() if r.get("action") != "retract"]
    return clean_keyed + no_key


def write_output(records: list[dict], output_path: Path) -> None:
    """Write records to output_path as newline-delimited JSON.

    Parameters
    ----------
    records : list[dict]
        Deduplicated records in canonical v2 schema.
    output_path : pathlib.Path
        Destination file.  Parent directories are created if absent.

    Notes
    -----
    Each record is serialised with ``sort_keys=True``, so object keys
    (at every nesting level) are written in a fixed alphabetical order.
    This keeps the output byte-for-byte reproducible across runs and
    makes line-level diffs between dataset snapshots meaningful.
    """
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with output_path.open("w", encoding="utf-8") as fh:
        for rec in records:
            fh.write(json.dumps(rec, ensure_ascii=False, sort_keys=True) + "\n")


def _report_stats(records: list[dict]) -> dict[str, Any]:
    """Return summary statistics for a list of records.

    Parameters
    ----------
    records : list[dict]
        Records to summarise (raw or deduplicated).

    Returns
    -------
    dict
        Counters by source, action, schema version, and FK population.
    """
    by_source: dict[str, int] = {}
    by_action: dict[str, int] = {}
    by_schema: dict[Any, int] = {}
    with_feedback_id = 0
    with_prev_feedback = 0
    tombstones = 0

    for r in records:
        src = r.get("_source", "unknown")
        by_source[src] = by_source.get(src, 0) + 1

        act = r.get("action", "rate")
        by_action[act] = by_action.get(act, 0) + 1

        sv = r.get("schemaVersion", "?")
        by_schema[sv] = by_schema.get(sv, 0) + 1

        if r.get("feedbackId"):
            with_feedback_id += 1
        if r.get("prevFeedbackId"):
            with_prev_feedback += 1
        if act == "retract":
            tombstones += 1

    return {
        "total": len(records),
        "by_source": by_source,
        "by_action": by_action,
        "by_schema": by_schema,
        "with_feedback_id": with_feedback_id,
        "with_prev_feedback_id": with_prev_feedback,
        "tombstones": tombstones,
    }


class _MaxLevelFilter(logging.Filter):
    """Admit only log records whose level is at or below *max_level*.

    Parameters
    ----------
    max_level : int
        Maximum ``logging`` level number (inclusive) to pass through.
        Records with a higher level number are suppressed.  Pass
        ``logging.INFO`` to block WARNING and above.

    Notes
    -----
    Attached to the stdout handler inside ``_configure_logging`` so that
    WARNING / ERROR records are handled exclusively by the stderr handler
    and are not duplicated on stdout.
    """

    def __init__(self, max_level: int) -> None:
        super().__init__()
        self.max_level = max_level

    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003
        """Return ``True`` if *record.levelno* is at or below *max_level*.

        Parameters
        ----------
        record : logging.LogRecord
            Log record to evaluate.

        Returns
        -------
        bool
            ``True`` to emit the record; ``False`` to suppress it.
        """
        return record.levelno <= self.max_level


def _configure_logging() -> None:
    """Attach stdout and stderr handlers to the root logger for CLI use.

    Routes INFO-level records to stdout with a plain ``%(message)s``
    format, and WARNING / ERROR / CRITICAL records to stderr with a
    ``[%(levelname)s] %(message)s`` format.

    This preserves the stdout / stderr split that the original ``print``
    / ``print(..., file=sys.stderr)`` calls provided:

    * Callers that capture stdout (e.g. downstream JSONL pipelines) see
      only the NDJSON data, never progress lines.
    * Diagnostic warnings and errors still appear on stderr.

    The function overwrites ``logging.root.handlers`` directly, so it is
    idempotent: repeated calls replace handlers rather than stacking
    duplicates.

    Notes
    -----
    This is a CLI-only helper.  Library callers that import the domain
    functions (``load_all_records``, ``deduplicate``, …) should configure
    their own logging handlers; this function is only invoked from
    ``main()``.
    """
    plain_fmt = logging.Formatter("%(message)s")
    level_fmt = logging.Formatter("[%(levelname)s] %(message)s")

    out_handler = logging.StreamHandler(sys.stdout)
    out_handler.setFormatter(plain_fmt)
    out_handler.setLevel(logging.DEBUG)
    out_handler.addFilter(_MaxLevelFilter(logging.INFO))

    err_handler = logging.StreamHandler(sys.stderr)
    err_handler.setFormatter(level_fmt)
    err_handler.setLevel(logging.WARNING)

    root = logging.getLogger()
    root.handlers = [out_handler, err_handler]
    root.setLevel(logging.DEBUG)


def main(argv: list[str] | None = None) -> int:
    """Run Main."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--repo-id",
        required=True,
        help="HuggingFace dataset repo ID, e.g. scikit-plots/ai-assistant-contributions",
    )
    parser.add_argument(
        "--output",
        default="clean_dataset.jsonl",
        help="Output path for the deduplicated NDJSON file (default: clean_dataset.jsonl)",
    )
    parser.add_argument(
        "--local-dir",
        default=None,
        help="Use a pre-downloaded local snapshot instead of downloading.",
    )
    parser.add_argument(
        "--token",
        default=None,
        help="HuggingFace read token (optional; uses cached token if absent).",
    )
    parser.add_argument(
        "--stats-only",
        action="store_true",
        help="Print dataset statistics without writing an output file.",
    )
    args = parser.parse_args(argv)
    _configure_logging()

    if not _SCHEMA_AVAILABLE:
        logger.warning(
            "_dataset_schema.py not found on sys.path.  Records will not be "
            "normalised from v1 to v2 schema.  Copy _dataset_schema.py from "
            "_hf_spaces_proxy/ to the same directory as this script for full "
            "schema normalisation.",
        )

    local_dir: Path
    if args.local_dir:
        local_dir = Path(args.local_dir)
    else:
        try:
            from huggingface_hub import snapshot_download  # noqa: PLC0415
        except ImportError:
            logger.error(
                "huggingface_hub is not installed.  "
                "Run: pip install 'huggingface_hub>=0.23,<2'",
            )
            return 1
        logger.info("Downloading %s ...", args.repo_id)
        local_dir = Path(
            snapshot_download(
                repo_id=args.repo_id,
                repo_type="dataset",
                token=args.token,
            )
        )

    logger.info("Reading records from %s ...", local_dir)
    all_records = load_all_records(local_dir)

    raw_stats = _report_stats(all_records)
    logger.info("  %d total records read", raw_stats["total"])
    for src, cnt in sorted(raw_stats["by_source"].items()):
        logger.info("    %s: %d", src, cnt)
    for act, cnt in sorted(raw_stats["by_action"].items()):
        logger.info("    action=%r: %d", act, cnt)
    for sv, cnt in raw_stats["by_schema"].items():
        logger.info("    schemaVersion=%s: %d", sv, cnt)
    logger.info("  feedbackId populated:      %d", raw_stats["with_feedback_id"])
    logger.info("  prevFeedbackId populated:  %d", raw_stats["with_prev_feedback_id"])
    if raw_stats["tombstones"]:
        logger.info(
            "  %d retraction tombstone(s) in raw data "
            "(always excluded from clean output)",
            raw_stats["tombstones"],
        )

    if args.stats_only:
        return 0

    clean = deduplicate(all_records)
    duplicates_removed = raw_stats["total"] - raw_stats["tombstones"] - len(clean)
    logger.info("  %d duplicate(s) removed (priority rule applied)", duplicates_removed)
    logger.info("  %d unique records retained", len(clean))

    output_path = Path(args.output)
    write_output(clean, output_path)
    logger.info("Clean dataset written to %s", output_path)
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

### Example run

```bash
pip install "huggingface_hub>=0.23,<2"

# Full pipeline (download + normalise + dedup):
python deduplicate_dataset.py \
    --repo-id scikit-plots/ai-assistant-contributions \
    --output  clean_dataset.jsonl \
    --token   hf_xxxxxxxxxxxxxxxx

# Stats-only (no output file written):
python deduplicate_dataset.py \
    --repo-id scikit-plots/ai-assistant-contributions \
    --stats-only \
    --token   hf_xxxxxxxxxxxxxxxx

# Faster re-runs using a pre-downloaded snapshot:
python deduplicate_dataset.py \
    --repo-id  scikit-plots/ai-assistant-contributions \
    --local-dir /tmp/ai-contributions-snapshot \
    --output   clean_dataset.jsonl
```

---

## 6. Field Reference (Post-Dedup Record, Canonical v2 Schema)

Every record in `clean_dataset.jsonl` uses the canonical key order from
`_dataset_schema.CANONICAL_COLUMNS`.  Old v1 records are normalised to
this schema by `normalize_record` in `_dataset_schema.py`.

### Schema metadata

| Field | Type | Description |
|-------|------|-------------|
| `schemaVersion` | `int` | `2` for all records normalised by this pipeline |

### Provenance (server-assigned)

| Field | Type | Description |
|-------|------|-------------|
| `_source` | `string` | `"contribution"` or `"feedback"` |
| `_ts` | `int` | Server receive timestamp (ms since epoch) |
| `_dedup_key` | `string` | `"{conversationId}:{answerIndex}"` β€” cross-source dedup key |

### Session identity

| Field | Type | Description |
|-------|------|-------------|
| `conversationId` | `string` | Stable per-page-load UUID (formerly `_sessionId` in contribution records) |
| `feedbackId` | `string \| null` | Per-rating event UUID. **Contribution records**: FK pointing at the matching `feedback/` record (`null` when the user contributed without rating individually). **Feedback records**: own idempotency UUID (formerly `sessionId`). |

### Record descriptor

| Field | Type | Description |
|-------|------|-------------|
| `answerIndex` | `int` | Zero-based position of the answer in the conversation |
| `action` | `"rate" \| "retract"` | `"retract"` tombstones are always excluded from clean output |
| `prevFeedbackId` | `string \| null` | `feedbackId` of the record this one supersedes. Set on `action="rate"` edits and `action="retract"` tombstones. `null` for a first-time rating. |
| `editCount` | `int \| null` | `0` for first rating, `+1` per edit. `null` for tombstones. |
| `status` | `"active" \| "retracted"` | Managed by the dedup pipeline |

### Rating

| Field | Type | Description |
|-------|------|-------------|
| `ratingValue` | `int \| null` | Signed integer: `-1`/`+1` for quick; `-5`...`+5` for panel |
| `ratingSlug` | `string \| null` | Snake_case canonical identifier (e.g. `"helpful"`, `"mostly_positive"`) |
| `ratingTitle` | `string \| null` | Human display string (e.g. `"Helpful"`, `"Mostly yes"`) |
| `ratingMode` | `"quick" \| "panel" \| null` | Which rating widget produced this record |
| `message` | `string` | Free-text comment (empty string when absent) |

### Conversation content

| Field | Type | Description |
|-------|------|-------------|
| `query` | `string` | User question |
| `answer` | `string` | Model response that was rated |

### Model attribution (8-key object)

| Field | Type | Description |
|-------|------|-------------|
| `model` | `dict \| null` | Full model attribution; `null` when no model was configured |
| `model.id` | `string \| null` | Canonical model identifier |
| `model.provider` | `string \| null` | Inference provider (e.g. `"huggingface"`, `"anthropic"`) |
| `model.model` | `string \| null` | HF model path or model string |
| `model.label` | `string \| null` | Human display name |
| `model.endpoint` | `string \| null` | Inference endpoint URL |
| `model.info_url` | `string \| null` | Model info/documentation link |
| `model.description` | `string \| null` | Short description |
| `model.default` | `bool \| null` | `True` when this was the default model |

### Context

| Field | Type | Description |
|-------|------|-------------|
| `page` | `string` | Originating documentation page URL |
| `consentVersion` | `null` | Reserved; always `null` while `CONSENT_VERSION_ENABLED = False` |
| `ts` | `int` | Client-side event timestamp (ms since epoch) |

> **Note** β€” retraction tombstones (`action="retract"`) are always excluded
> from `clean_dataset.jsonl`.  The `action` field therefore never appears in
> the clean output described by this table.

---

## 7. Enabling Feedback Persistence

By default, `POST /v1/feedback` is **log-only** (no dataset writes).  To enable
dual-source collection:

1. Set environment variable `FEEDBACK_PERSIST_ENABLED=true` in your HF Space
   (Settings -> Repository secrets).
2. Ensure `TRAINING_DATASET_REPO` and `HF_WRITE_TOKEN` (or `HF_TOKEN`) are also
   set.
3. Without both of the above, `FEEDBACK_PERSIST_ENABLED` has no effect.

> **Important:** enabling this flag means duplicates **will** appear in the
> dataset whenever a user both rates answers AND clicks Contribute.  Always run
> `deduplicate_dataset.py` before training.

---

## 8. Consent Version (Reserved)

`consentVersion` is collected for future use but **not currently enforced**.

| Setting | Location | Current value |
|---------|----------|---------------|
| `CONSENT_VERSION_ENABLED` | `_dataset_schema.py` | `False` |
| `CONSENT_VERSION_ENFORCEMENT_ENABLED` | `app.py` | `False` |
| `RESERVED_CONSENT_VERSION` | `_dataset_schema.py` | `"1.0.0"` |
| JS constant | `ai-assistant.js` | commented out (`// var CONSENT_VERSION = '1.0.0'`) |

While both flags are `False`, all stored records have `consentVersion: null`
regardless of what the client sends, including historical records that stored
`"v1.0"`.  To activate enforcement, flip both flags to `True`, uncomment the
JS constant, and set `TRAINING_CONSENT_VERSION` in `app.py`.

---

## 9. Summary of Changes

### v1.0 (initial)

| Component | Change |
|-----------|--------|
| `app.py` | Added `FEEDBACK_PERSIST_ENABLED` constant |
| `app.py /v1/contribute` | Stored JSONL records carry `_source="contribution"` and `_dedup_key` |
| `app.py /v1/feedback` | Optional HF persistence; stored records carry `_source="feedback"` and `_dedup_key` |
| `app.py /v1/feedback` | Retraction tombstones detected, validated, rate-limit-exempt, committed with distinct message |
| `ai-assistant.js` | Added `conversationId: _sessionId` to feedback POST payload |
| `ai-assistant.js _feedbackStore` | Added `conversationId` field |
| `ai-assistant.js tRecords` | Added `_source: 'contribution'` to each record |
| `deduplicate_dataset.py` | Post-loop filter removes tombstone winners from clean output |

### v2.0 (current β€” additive, backward compatible)

| Component | Change |
|-----------|--------|
| `_dataset_schema.py` | `SCHEMA_VERSION` bumped 1->2; new `editCount` column; `feedbackId` FK on contribution records; `prevFeedbackId` on `action="rate"` edits; `consentVersion` always null; `_safe_id`/`_safe_int` guards; 8-key model shape unified |
| `_dataset_schema.py` | `normalize_feedback_record` / `normalize_contribution_record` always write `SCHEMA_VERSION` (2) β€” fixes `int(1 or 2) = 1` Python short-circuit bug that stored v1 headers on v2-content records |
| `app.py` | `TRAINING_CONSENT_VERSION` hardcoded check replaced by `CONSENT_VERSION_ENFORCEMENT_ENABLED = False` flag β€” eliminates 422 errors for `consentVersion: null` payloads |
| `app.py` | `supported_versions` expanded `{1}->{1, 2}` to accept updated JS clients |
| `ai-assistant.js` | `schemaVersion: 1->2` in all four payload sites (quick feedback, panel `_rebuildFeedbackFormIn`, panel `_buildFeedbackBlock`, contribution envelope) |
| `ai-assistant.js` | `feedbackId`, `prevFeedbackId`, `editCount` forwarded in `tRecords` from `_feedbackStore` |
| `ai-assistant.js` | Shared `_buildModelInfo(cfg)` helper produces canonical 8-key model for all sites |
| `deduplicate_dataset.py` | Integrates `_dataset_schema.normalize_record` for v1->v2 normalisation; adds `--stats-only` flag; reports schema version breakdown and FK population counts |
| `DATASET_COLLECTION_GUIDANCE.md` | Updated to v2; revised field reference; updated folder structure; v2 dedup script |

---

## 10. Viewing the Dataset from the AI Panel

The Extended Settings sheet (Endpoint Configuration -> **Dataset Endpoint**)
surfaces the dataset directly in the browser, with no secret ever leaving the
server.

How the panel resolves the dataset repo (two sources, first match wins):

1. **Explicit** β€” `ai_assistant_panel_dataset_repo = "org/repo"` in `conf.py`.
   Highest trust; no network call. Use for offline docs or to pin a specific repo.
2. **Auto-discovery** β€” when a training URL is configured, the panel issues
   `GET {proxyBase}/` and reads `training.dataset_repo` from the JSON.

From the repo id the panel builds three clickable links:

| Card | URL |
|------|-----|
| Dataset root | `https://huggingface.co/datasets/<repo>` |
| Feedback records | `https://huggingface.co/datasets/<repo>/tree/main/feedback` |
| Contributions | `https://huggingface.co/datasets/<repo>/tree/main/contributions` |