Spaces:
Running
Running
File size: 17,222 Bytes
a13c429 cb788eb a13c429 cb788eb a13c429 cb788eb a13c429 cb788eb a13c429 | 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 | # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/deduplicate_dataset.py
#
# flake8: noqa: D213
#
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause
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: import _RedactingFilter from _shared_logic when available so that
# HF token strings embedded in exception messages (e.g. snapshot_download auth
# failures) are scrubbed from CLI log output. Safe no-op fallback for
# standalone usage where _shared_logic.py is absent.
try:
from _shared_logic import _RedactingFilter as _REDACTING_FILTER_CLS
except ImportError:
_REDACTING_FILTER_CLS = None # type: ignore[assignment,misc]
# 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)
# Attach defence-in-depth redaction filter when _shared_logic is available.
# Scrubs HF token strings that huggingface_hub may embed in auth-error
# messages before they are emitted to stderr. No-op when absent.
if _REDACTING_FILTER_CLS is not None:
_rf = _REDACTING_FILTER_CLS()
out_handler.addFilter(_rf)
err_handler.addFilter(_rf)
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)
try:
local_dir = Path(
snapshot_download(
repo_id=args.repo_id,
repo_type="dataset",
token=args.token,
)
)
except Exception as exc: # noqa: BLE001
logger.error(
"Failed to download %s: %s\n"
"Hint: pass --token <HF_READ_TOKEN> or set HF_TOKEN in your environment.",
args.repo_id,
exc,
)
return 1
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())
|