ai / DATASET_COLLECTION_GUIDANCE.md
celik-muhammed's picture
Upload 8 files
a13c429 verified
|
Raw
History Blame Contribute Delete
32.8 kB

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.

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

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