Spaces:
Running
Running
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` |
|