ai / _utils /_dataset_schema.py
celik-muhammed's picture
Upload 34 files
89e4531 verified
Raw
History Blame Contribute Delete
54.9 kB
# scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/_utils/_dataset_schema.py
#
# flake8: noqa: D213
#
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause
"""Canonical schema and normalization for collection records.
Schema v4 separates telemetry from two explicit contribution record families:
* ``feedback`` is privacy-minimal rating telemetry. Content, model, page and
conversation identity are discarded even when legacy/direct callers submit them;
``trainingStatus`` is ``telemetry`` for privacy-minimal rating telemetry.
Explicit content-bearing feedback review is a separate consented path and may
carry future ``eligible`` bytes that become canonical only after maintainer merge.
* ``contribution`` is explicit-content intake. Q&A records retain the historical
``query``/``answer`` shape while conversation records carry one ordered ``messages``
array. Both carry versioned consent, enter ``quarantined`` state, and are
training-eligible only after an authorised review promotes them.
Historical v1/v2/v3 rows remain readable through :func:`normalize_record`, but old
contributions become ``legacy_unreviewed`` rather than silently entering training.
Client IP addresses are never dataset fields. See ``DATASET_COLLECTION_GUIDANCE.md``
for lifecycle and retention policy.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Schema constants
# ─────────────────────────────────────────────────────────────────────────────
#: Current schema version for records written by this module.
#: Increment when a breaking field-name change is introduced; additive
#: changes (new optional columns, wider population of existing columns) bump
#: this too so consumers can branch on ``schemaVersion`` to know which fields
#: to expect. See the module docstring and collection guidance for version semantics.
SCHEMA_VERSION: int = 4
#: Ordered list of canonical column names. Every stored JSONL row and every
#: row in the pandas DataFrame will have these columns in exactly this order.
CANONICAL_COLUMNS: list[str] = [
# ── Schema metadata ───────────────────────────────────────────────────────
"schemaVersion",
# ── Provenance (server-side, mandatory) ──────────────────────────────────
"_source", # "feedback" | "contribution"
"_ts", # server receive time, ms since epoch (int)
"_dedup_key", # server event/receipt scoped key; never a stable user identity
# ── Event identity ────────────────────────────────────────────────────────
"conversationId", # legacy field; v3 feedback/contribution normalization writes None
"feedbackId", # feedback event id only; contributions write None
# ── Record descriptor ─────────────────────────────────────────────────────
"recordType", # "qa" | "conversation" (telemetry writes None; reviewed feedback writes "qa")
"answerIndex", # 0-based position of answer in the conversation
"action", # "rate" | "retract" | "review" | "withdraw"
"prevFeedbackId", # feedbackId of the record this one supersedes/invalidates.
# action="rate": set when this rating replaces an earlier
# one for the same answerIndex (an edit).
# action="retract": set to the feedbackId being retracted.
# None for a first-time rating.
"editCount", # int: 0 for the first rating; +1 each time the user
# edits/re-rates the same answer (mirrors prevFeedbackId
# chain length without walking it). None for retracts.
"status", # "active" | "retracted" (dedup pipeline manages)
"trainingStatus", # "telemetry" | "reviewed" | "quarantined" | "eligible" | "withdrawn" | "legacy_unreviewed"
# ── Rating ────────────────────────────────────────────────────────────────
"ratingValue", # int | None: numeric score (-5..+5 for panel; -1|+1 for quick)
"ratingSlug", # str | None: snake_case canonical slug ("helpful", "mostly_positive")
"ratingTitle", # str | None: human display string ("Helpful", "Mostly yes")
"ratingMode", # str | None: "quick" | "panel"
"ratingScaleMin", # numeric lower bound used to normalize reviewed feedback quality
"ratingScaleMax", # numeric upper bound used to normalize reviewed feedback quality
"qualityScore", # float | None: normalized answer quality in [0, 1]
"qualityPercent", # float | None: qualityScore * 100, rounded for dashboards
"message", # contribution text only; feedback telemetry writes empty string
# ── Conversation content ──────────────────────────────────────────────────
"query", # contribution user question; feedback telemetry writes empty string
"answer", # Q&A contribution model response; feedback telemetry/conversations write empty string
"messages", # conversation contribution ordered message list; otherwise None
# ── Model ────────────────────────────────────────────────────────────────
"model", # dict | None: normalised 8-key model object (see MODEL_KEYS)
"modelEvidence", # None | "client_reported" | "legacy_unverified"
# ── Context ───────────────────────────────────────────────────────────────
"page", # str: documentation page URL
"consentVersion", # str | None: review/contribution sharing consent version
"trainingConsentVersion", # str | None: explicit consent version for training eligibility
# ── Timestamps ───────────────────────────────────────────────────────────
"ts", # int: client-side event time, ms since epoch
]
#: Required keys for the normalised model sub-object.
#: Legacy/model-bearing contribution shapes are expanded to
#: this full set; keys absent in the source are filled with ``None``.
MODEL_KEYS: list[str] = [
"id", # canonical model identifier (e.g. "Qwen2.5-Coder-7B-Instruct-hf")
"provider", # inference provider (e.g. "huggingface", "anthropic", "custom")
"model", # HF model path or model string (e.g. "Qwen/Qwen2.5-Coder-7B-Instruct")
"label", # human display name (e.g. "Qwen2.5-Coder-7B-Instruct (Qwen/HuggingFace)")
"endpoint", # inference endpoint URL (None when not configured)
"info_url", # documentation/info link for this model
"description", # short description text
"default", # bool | None: True when this is the default model in the config
]
# ─────────────────────────────────────────────────────────────────────────────
# Consent-version handling
# ─────────────────────────────────────────────────────────────────────────────
#: Current contribution consent is versioned and enforced. Bump this value
#: whenever the displayed contribution terms change materially and update the
#: browser ``CONSENT_VERSION`` in the same run.
CONSENT_VERSION_ENABLED: bool = True
RESERVED_CONSENT_VERSION: str = "2.0.0"
FEEDBACK_TELEMETRY_CONSENT_VERSION: str = "1.0.0"
FEEDBACK_TELEMETRY_SCHEMA_VERSION: int = 4
LEGACY_CONSENT_VERSIONS: frozenset[str] = frozenset({"1.0.0"})
def _resolve_consent_version(raw: Any) -> str | None:
"""Resolve the ``consentVersion`` field for a normalised record.
Parameters
----------
raw : Any
The raw ``consentVersion``-like value from the payload or a
previously stored record (feedback payloads never had one;
contribution envelopes/records may carry ``"v1.0"`` or ``null``).
Returns
-------
str or None
the declared non-empty consent version while enforcement is enabled, else ``None`` (this function
never *invents* a consent version for a record that did not declare
one β€” :data:`RESERVED_CONSENT_VERSION` is purely documentation for
what the JS widget should send once re-enabled).
Notes
-----
Developer note
Centralising this here means flipping :data:`CONSENT_VERSION_ENABLED`
is the *only* code change needed in this module; both normalisers and
:func:`normalize_record` already call this function.
Examples
--------
>>> _resolve_consent_version("2.0.0")
'2.0.0'
>>> _resolve_consent_version(None)
"""
if not CONSENT_VERSION_ENABLED:
return None
return raw if isinstance(raw, str) and raw else None
# ─────────────────────────────────────────────────────────────────────────────
# Defensive ID coercion
# ─────────────────────────────────────────────────────────────────────────────
#: Hard upper bound on stored identifier strings (``feedbackId``,
#: ``prevFeedbackId``, ``conversationId``). Generated values are plain UUIDs
#: (36 chars) for all records written going forward; legacy quick-feedback
#: records may carry the longer ``"{uuid}-quick-{idx}-{ts}"`` composite (see
#: :data:`_QUICK_SESSION_RE`), still well under 100 chars. 256 leaves
#: generous headroom while bounding worst-case row size if a malformed or
#: malicious client sends an oversized string.
_MAX_ID_LEN: int = 256
def _safe_id(value: Any) -> str | None:
"""Coerce a client-supplied identifier to a bounded ``str`` or ``None``.
Parameters
----------
value : Any
Raw value from the client payload (expected: ``str`` or ``None``/
absent). Any non-string (e.g. an accidental ``int``, ``list``, or
``dict`` from a malformed client) is treated as absent.
Returns
-------
str or None
``None`` for falsy/non-string input. Otherwise the string,
truncated to :data:`_MAX_ID_LEN` characters.
Notes
-----
Developer note β€” Security
Applied to every ``*FeedbackId`` / ``conversationId`` field written by
the normalisers. Prevents a malformed or adversarial payload (wrong
type, or a multi-MB string) from being written verbatim into the
dataset. Truncation is preferred over rejection so a single bad field
does not fail an otherwise-valid submission β€” see Principle 2 (no
silent failures): truncation is itself loud in the sense that a
truncated UUID will simply never match anything in
``deduplicate_dataset.py``'s join logic, which is the correct,
self-healing outcome for a corrupted ID.
Examples
--------
>>> _safe_id("57b73883-ba14-4a0c-ac38-79bc76a2c0ee")
'57b73883-ba14-4a0c-ac38-79bc76a2c0ee'
>>> _safe_id(None)
>>> _safe_id(12345)
>>> _safe_id("x" * 300)[-1] == "x" and len(_safe_id("x" * 300)) == 256
True
"""
if not isinstance(value, str) or not value:
return None
return value[:_MAX_ID_LEN]
def _safe_int(value: Any, default: int = 0) -> int:
"""Coerce a client-supplied count to a non-negative ``int``.
Parameters
----------
value : Any
Raw value (expected: small non-negative ``int``). ``bool`` is
rejected even though ``bool`` is a subclass of ``int`` in Python,
since a stray ``True``/``False`` here indicates a client bug, not a
real edit count.
default : int, optional
Value returned for missing/invalid input. Default ``0``.
Returns
-------
int
``max(0, int(value))`` when ``value`` is a non-bool ``int``/``float``
representing a whole number; otherwise ``default``.
Examples
--------
>>> _safe_int(3)
3
>>> _safe_int(-1)
0
>>> _safe_int(None)
0
>>> _safe_int(True)
0
"""
if isinstance(value, bool):
return default
if isinstance(value, int):
return max(0, value)
if isinstance(value, float) and value.is_integer():
return max(0, int(value))
return default
# ── Rating vocabulary ─────────────────────────────────────────────────────────
# The panel feedback 11-point scale. ``value`` here is the slug stored as
# ``ratingLabel`` in the JS source (_FEEDBACK_DEFAULTS[idx].value).
# The numeric rating is carried in ``ratingValue`` (-5 to +5 mapping to index 0..10).
# fmt: off
_PANEL_SCALE: list[dict[str, Any]] = [
{"slug": "terrible", "title": "Terrible", "scale": -5},
{"slug": "poor", "title": "Poor", "scale": -4},
{"slug": "unsatisfied", "title": "Unsatisfied", "scale": -3},
{"slug": "negative", "title": "No", "scale": -2},
{"slug": "slightly_negative", "title": "Not really", "scale": -1},
{"slug": "neutral", "title": "Neutral", "scale": 0},
{"slug": "slightly_positive", "title": "Somewhat", "scale": +1},
{"slug": "mostly_positive", "title": "Mostly yes", "scale": +2},
{"slug": "good", "title": "Good", "scale": +3},
{"slug": "very_good", "title": "Very good", "scale": +4},
{"slug": "excellent", "title": "Excellent!", "scale": +5},
]
# fmt: on
# The quick πŸ‘/πŸ‘Ž options. ``sentiment`` is used as the canonical slug
# (after the JS-side fix; old records stored ``title`` in ``ratingLabel``).
_QUICK_OPTS: list[dict[str, Any]] = [
{
"slug": "not_helpful",
"title": "Not helpful",
"value": -1,
"sentiment": "negative",
},
{"slug": "helpful", "title": "Helpful", "value": +1, "sentiment": "positive"},
]
#: Set of slug values associated with quick (πŸ‘/πŸ‘Ž) feedback options.
#: Disjoint from all panel slugs β€” used for deterministic ratingMode detection
#: when ``ratingMode`` is not explicitly provided in the payload (old records).
_QUICK_SLUGS: frozenset[str] = frozenset(e["slug"] for e in _QUICK_OPTS)
#: Set of sentiment strings used as quick feedback mode indicators.
#: Old records written before the slug fix may carry "positive"/"negative" here.
_QUICK_SENTIMENTS: frozenset[str] = frozenset(e["sentiment"] for e in _QUICK_OPTS)
#: All identifiers that unambiguously indicate quick (πŸ‘/πŸ‘Ž) rating mode.
_QUICK_IDENTIFIERS: frozenset[str] = _QUICK_SLUGS | _QUICK_SENTIMENTS
# Derived lookup tables.
_SLUG_TO_TITLE: dict[str, str] = {
**{e["slug"]: e["title"] for e in _PANEL_SCALE},
**{e["slug"]: e["title"] for e in _QUICK_OPTS},
# Sentiment strings also accepted as slugs (old records may use "positive"/"negative").
**{e["sentiment"]: e["title"] for e in _QUICK_OPTS},
}
_TITLE_TO_SLUG: dict[str, str] = {
**{e["title"]: e["slug"] for e in _PANEL_SCALE},
**{e["title"]: e["slug"] for e in _QUICK_OPTS},
}
_SLUG_TO_SCALE: dict[str, int] = {e["slug"]: e["scale"] for e in _PANEL_SCALE}
_SCALE_TO_SLUG: dict[int, str] = {e["scale"]: e["slug"] for e in _PANEL_SCALE}
_VALUE_TO_QUICK: dict[int, dict] = {e["value"]: e for e in _QUICK_OPTS}
#: All known Title Case rating strings (old quick records use these in ratingLabel).
_KNOWN_TITLES: frozenset[str] = frozenset(_TITLE_TO_SLUG)
#: Regex that matches a valid snake_case slug (all lowercase + underscores).
_SLUG_RE: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9_]*[a-z0-9]$|^[a-z]$")
#: Regex detecting the LEGACY (pre-v2) quick-feedback ``feedbackId``/``sessionId``
#: format generated by older versions of the JS widget:
#: ``<conversationUUID>-quick-<answerIndex>-<ms-epoch>``.
#:
#: Since schema v2, ``feedbackId`` for *new* records is always a plain UUID
#: (``crypto.randomUUID()``) for **both** quick and panel feedback β€” the
#: ``-quick-N-ts`` suffix was redundant once ``ratingMode``, ``answerIndex``,
#: and ``ts`` became separately-stored canonical fields, and made
#: ``feedbackId``'s format inconsistent across rating modes (see the JS-side
#: comment at the ``sessionId`` assignment in the quick-feedback handler).
#: New records always carry an explicit ``ratingMode`` in the payload, so this
#: regex is consulted only as a fallback for OLD records written before that
#: field existed β€” kept for :func:`normalize_record` back-compat when reading
#: historical ``feedback/*.jsonl`` files. Do not rely on this pattern matching
#: any record written going forward.
_QUICK_SESSION_RE: re.Pattern[str] = re.compile(r"-quick-\d+-\d+$")
# ─────────────────────────────────────────────────────────────────────────────
# Model normalization
# ─────────────────────────────────────────────────────────────────────────────
def normalize_model(raw: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return a normalised model object with all ``MODEL_KEYS`` present.
Parameters
----------
raw : dict or None
Raw model dict from either a feedback record (3-key shape:
``{id, provider, model}``) or a contribution record (8-key shape:
``{id, provider, model, label, endpoint, info_url, description, default}``).
``None`` is returned unchanged.
Returns
-------
dict or None
All eight canonical keys present; absent source keys are ``None``.
Notes
-----
Developer note
This ensures ``df["model"].apply(lambda m: m["label"])`` works uniformly
across rows from both sources without ``KeyError``.
Examples
--------
>>> normalize_model({"id": "foo", "provider": "hf", "model": "Org/foo"})
{'id': 'foo', 'provider': 'hf', 'model': 'Org/foo', 'label': None,
'endpoint': None, 'info_url': None, 'description': None, 'default': None}
"""
if raw is None:
return None
if not isinstance(raw, dict):
return None
return {k: raw.get(k) for k in MODEL_KEYS}
# ─────────────────────────────────────────────────────────────────────────────
# Rating normalization
# ─────────────────────────────────────────────────────────────────────────────
def normalize_rating( # noqa: PLR0912
rating_value: int | None,
rating_label: str | None,
*,
rating_mode: str | None = None,
rating_title: str | None = None,
feedback_id: str | None = None,
) -> dict[str, Any]:
"""Derive canonical (ratingSlug, ratingTitle, ratingMode) from raw inputs.
Parameters
----------
rating_value : int or None
Numeric rating score. Quick feedback uses -1/+1; panel uses -5..+5.
rating_label : str or None
Raw ``ratingLabel`` from the client payload. This may be:
* A snake_case slug (``"mostly_positive"``): panel feedback and all
records written after the JS-side fix.
* A Title Case string (``"Not helpful"``): old quick-feedback records
written before the JS-side fix.
* A sentiment string (``"positive"``/``"negative"``): transitional.
rating_mode : str or None, optional
``"quick"`` or ``"panel"`` when the JS widget sends the new
``ratingMode`` field. Autodetected from ``feedback_id`` and
``rating_label`` when absent.
rating_title : str or None, optional
Human display string when the JS widget sends the new ``ratingTitle``
field. Derived from ``ratingSlug`` when absent.
feedback_id : str or None, optional
The per-submission ``feedbackId`` / ``sessionId``; used to autodetect
quick-feedback records by the ``-quick-`` pattern in older JS versions.
Returns
-------
dict
Keys: ``ratingSlug``, ``ratingTitle``, ``ratingMode``.
All values are ``str`` or ``None``.
Notes
-----
Developer note β€” Detection order:
1. If ``rating_mode`` is already provided: use it directly.
2. If ``feedback_id`` matches ``_QUICK_SESSION_RE``: quick mode.
3. If ``rating_label`` is a known Title Case string: quick mode (old record).
4. If ``rating_label`` is snake_case slug: panel mode.
5. If ``rating_value`` is -1 or +1 and ``rating_label`` is absent: quick mode.
6. Otherwise: panel mode (safe default).
Examples
--------
>>> normalize_rating(1, "Helpful") # old quick record
{'ratingSlug': 'helpful', 'ratingTitle': 'Helpful', 'ratingMode': 'quick'}
>>> normalize_rating(2, "mostly_positive") # panel record
{'ratingSlug': 'mostly_positive', 'ratingTitle': 'Mostly yes', 'ratingMode': 'panel'}
>>> normalize_rating(1, "helpful", rating_mode="quick") # new quick record
{'ratingSlug': 'helpful', 'ratingTitle': 'Helpful', 'ratingMode': 'quick'}
"""
label_str: str = (rating_label or "").strip()
detected_mode: str | None = rating_mode
# ── Step 1: Autodetect mode ───────────────────────────────────────────────
if not detected_mode:
if (
feedback_id and _QUICK_SESSION_RE.search(feedback_id)
) or label_str in _KNOWN_TITLES:
detected_mode = "quick"
elif label_str and _SLUG_RE.match(label_str):
# Slug-based mode detection: quick slugs ("helpful", "not_helpful")
# and panel slugs ("mostly_positive", "excellent", …) are disjoint
# sets β€” membership check is sufficient and deterministic.
# This handles contribution records where _feedbackStore.ratingMode
# is forwarded in ratingMode (new JS) but also back-compats old
# records that only carried ratingLabel (slug or Title Case).
detected_mode = "quick" if label_str in _QUICK_IDENTIFIERS else "panel"
elif rating_value in (-1, 1) and not label_str:
detected_mode = "quick"
else:
detected_mode = "panel"
# ── Step 2: Derive slug ───────────────────────────────────────────────────
slug: str | None
if detected_mode == "quick":
if label_str in _TITLE_TO_SLUG:
# Old record: ratingLabel held the Title Case string.
slug = _TITLE_TO_SLUG[label_str]
elif label_str in _SLUG_TO_TITLE:
# New record or sentiment string already slug-like.
slug = label_str
elif rating_value in _VALUE_TO_QUICK:
slug = _VALUE_TO_QUICK[rating_value]["slug"]
else:
slug = None
else:
# Panel mode: ratingLabel is already a slug (or empty for retracts).
slug = label_str if (label_str and _SLUG_RE.match(label_str)) else None
# If slug missing but scale value present, derive from _SCALE_TO_SLUG.
if slug is None and rating_value is not None:
slug = _SCALE_TO_SLUG.get(rating_value)
# ── Step 3: Derive title ──────────────────────────────────────────────────
title: str | None
if rating_title:
title = rating_title # Explicit (new JS sends ratingTitle)
elif slug:
title = _SLUG_TO_TITLE.get(slug)
else:
title = None
return {
"ratingSlug": slug,
"ratingTitle": title,
"ratingMode": detected_mode if (slug is not None) else None,
}
# ─────────────────────────────────────────────────────────────────────────────
# Canonical record construction
# ─────────────────────────────────────────────────────────────────────────────
def _ordered(fields: dict[str, Any]) -> dict[str, Any]:
"""Return ``fields`` re-ordered to match ``CANONICAL_COLUMNS``.
Parameters
----------
fields : dict
Record dict with all canonical keys present.
Returns
-------
dict
Keys in ``CANONICAL_COLUMNS`` order; extra keys appended alphabetically.
"""
ordered: dict[str, Any] = {}
for col in CANONICAL_COLUMNS:
ordered[col] = fields.get(col)
# Preserve any unexpected extra keys after the canonical set (future fields).
for k in sorted(fields):
if k not in ordered:
ordered[k] = fields[k]
return ordered
def normalize_feedback_record(
payload: dict[str, Any],
*,
server_ts_ms: int,
) -> dict[str, Any]:
"""Normalize ordinary feedback to privacy-minimal telemetry.
Feedback is not a training-data collection channel. Direct/legacy callers
may still submit historical fields such as ``query``, ``answer``, ``message``,
``model``, ``page`` or ``conversationId``; they are deliberately discarded.
Only bounded rating mechanics are retained.
"""
is_retract = payload.get("action") == "retract"
feedback_id = _safe_id(payload.get("feedbackId") or payload.get("sessionId"))
prev_feedback_id = _safe_id(
payload.get("prevFeedbackId") or payload.get("prevSessionId")
)
answer_index = payload.get("answerIndex")
try:
answer_index = int(answer_index) if answer_index is not None else None
except (TypeError, ValueError):
answer_index = None
if is_retract:
rating_fields = {"ratingSlug": None, "ratingTitle": None, "ratingMode": None}
else:
rating_fields = normalize_rating(
payload.get("ratingValue"),
payload.get("ratingLabel"),
rating_mode=payload.get("ratingMode"),
rating_title=payload.get("ratingTitle"),
feedback_id=feedback_id,
)
# Deliberately avoid a conversation/session linkage key. A persisted rating
# is telemetry only and is never eligible for the training builder.
dedup = f"{feedback_id}:feedback" if feedback_id else None
return _ordered(
{
"schemaVersion": SCHEMA_VERSION,
"_source": "feedback",
"_ts": server_ts_ms,
"_dedup_key": dedup,
"conversationId": None,
"feedbackId": feedback_id,
"recordType": None,
"answerIndex": answer_index,
"action": "retract" if is_retract else "rate",
"prevFeedbackId": prev_feedback_id,
"editCount": (
None if is_retract else _safe_int(payload.get("editCount"), default=0)
),
"status": "active",
"trainingStatus": "telemetry",
"ratingValue": None if is_retract else payload.get("ratingValue"),
"ratingSlug": rating_fields["ratingSlug"],
"ratingTitle": rating_fields["ratingTitle"],
"ratingMode": rating_fields["ratingMode"],
"message": "",
"query": "",
"answer": "",
"messages": None,
"model": None,
"modelEvidence": None,
"page": "",
"consentVersion": None,
"ts": payload.get("ts"),
}
)
def normalize_feedback_review_record(
payload: dict[str, Any],
*,
server_ts_ms: int,
receipt_id: str,
) -> dict[str, Any]:
"""Normalize explicitly consented Q&A feedback for provider review.
This is intentionally distinct from privacy-minimal feedback telemetry. The
reader authorizes one Q&A, rating, optional note, and training use if a
maintainer accepts the native PR/MR. The review ref therefore carries the
*future canonical* ``trainingStatus=eligible`` bytes, while the API/ledger
continues to report ``trainingEligible=false`` until the provider review is
actually merged.
"""
feedback_id = _safe_id(payload.get("feedbackId") or payload.get("sessionId"))
answer_index = payload.get("answerIndex")
try:
answer_index = int(answer_index) if answer_index is not None else None
except (TypeError, ValueError):
answer_index = None
rating_fields = normalize_rating(
payload.get("ratingValue"),
payload.get("ratingLabel"),
rating_mode=payload.get("ratingMode"),
rating_title=payload.get("ratingTitle"),
feedback_id=feedback_id,
)
rating_value = float(payload.get("ratingValue"))
rating_min = float(payload.get("ratingScaleMin"))
rating_max = float(payload.get("ratingScaleMax"))
quality_score = (rating_value - rating_min) / (rating_max - rating_min)
quality_score = max(0.0, min(1.0, quality_score))
quality_percent = round(quality_score * 100.0, 2)
model = payload.get("model")
if not isinstance(model, dict):
model = None
return _ordered(
{
"schemaVersion": SCHEMA_VERSION,
"_source": "feedback",
"_ts": server_ts_ms,
"_dedup_key": f"{receipt_id}:feedback" if receipt_id else None,
"conversationId": None,
"feedbackId": feedback_id,
"recordType": "qa",
"answerIndex": answer_index,
"action": "review",
"prevFeedbackId": _safe_id(payload.get("prevFeedbackId")),
"editCount": _safe_int(payload.get("editCount"), default=0),
"status": "active",
"trainingStatus": "eligible",
"ratingValue": payload.get("ratingValue"),
"ratingSlug": rating_fields["ratingSlug"],
"ratingTitle": rating_fields["ratingTitle"],
"ratingMode": rating_fields["ratingMode"],
"ratingScaleMin": rating_min,
"ratingScaleMax": rating_max,
"qualityScore": round(quality_score, 6),
"qualityPercent": quality_percent,
"message": _bounded_text(
payload.get("message"), limit=_MAX_CONTRIBUTION_NOTE_CHARS
),
"query": _bounded_text(
payload.get("query"), limit=_MAX_CONVERSATION_MESSAGE_CHARS
),
"answer": _bounded_text(
payload.get("answer"), limit=_MAX_CONVERSATION_MESSAGE_CHARS
),
"messages": None,
"model": normalize_model(model) if model else None,
"modelEvidence": "client_selected" if model else None,
"page": _bounded_text(payload.get("page"), limit=2048),
"consentVersion": str(payload.get("consentVersion") or "")[:32] or None,
"trainingConsentVersion": (
str(payload.get("trainingConsentVersion") or "")[:32] or None
),
"ts": payload.get("ts"),
"feedbackReview": True,
}
)
_MAX_CONVERSATION_MESSAGES: int = 100
_MAX_CONVERSATION_MESSAGE_CHARS: int = 100_000
_MAX_CONTRIBUTION_NOTE_CHARS: int = 2_000
# Public contract aliases used by browser/server parity validation. The
# normalizer keeps defensive bounds for legacy rows, while current schema-v4
# intake rejects over-limit reviewed content instead of silently truncating it.
MAX_CONVERSATION_MESSAGES: int = _MAX_CONVERSATION_MESSAGES
MAX_CONVERSATION_MESSAGE_CHARS: int = _MAX_CONVERSATION_MESSAGE_CHARS
MAX_CONTRIBUTION_NOTE_CHARS: int = _MAX_CONTRIBUTION_NOTE_CHARS
def _bounded_text(value: Any, *, limit: int) -> str:
"""Return a bounded string for explicit contribution content."""
if not isinstance(value, str):
return ""
return value[:limit]
def normalize_conversation_messages(value: Any) -> list[dict[str, Any]]:
"""Normalize one explicit whole-conversation message array.
Only ``user`` and ``assistant`` roles are accepted. Error/tool/system rows are
deliberately excluded from this training/evaluation contribution family.
Per-assistant model and rating metadata remain client-reported evidence.
"""
if not isinstance(value, list):
return []
out: list[dict[str, Any]] = []
for raw in value[:_MAX_CONVERSATION_MESSAGES]:
if not isinstance(raw, dict):
continue
role = raw.get("role")
if role not in {"user", "assistant"}:
continue
content = _bounded_text(
raw.get("content"), limit=_MAX_CONVERSATION_MESSAGE_CHARS
)
if not content:
continue
item: dict[str, Any] = {
"role": role,
"content": content,
"ts": (
raw.get("ts")
if isinstance(raw.get("ts"), (int, float))
and not isinstance(raw.get("ts"), bool)
else None
),
}
if role == "assistant":
raw_model = raw.get("model")
item["model"] = (
normalize_model(raw_model) if isinstance(raw_model, dict) else None
)
raw_feedback = raw.get("feedback")
if isinstance(raw_feedback, dict):
rating = normalize_rating(
raw_feedback.get("ratingValue"),
raw_feedback.get("ratingLabel"),
rating_mode=raw_feedback.get("ratingMode"),
rating_title=raw_feedback.get("ratingTitle"),
feedback_id=None,
)
item["feedback"] = {
"ratingValue": raw_feedback.get("ratingValue"),
"ratingSlug": rating["ratingSlug"],
"ratingTitle": rating["ratingTitle"],
"ratingMode": rating["ratingMode"],
"note": _bounded_text(
raw_feedback.get("note"), limit=_MAX_CONTRIBUTION_NOTE_CHARS
),
}
else:
item["feedback"] = None
out.append(item)
return out
def normalize_contribution_record(
rec: dict[str, Any],
*,
envelope: dict[str, Any],
server_ts_ms: int,
training_status: str = "quarantined",
submission_id: str | None = None,
) -> dict[str, Any]:
"""Normalize one explicitly consented Q&A or conversation contribution."""
if training_status not in {"quarantined", "eligible", "legacy_unreviewed"}:
training_status = "quarantined"
dedup_base = _safe_id(submission_id) or "pending"
declared_type = rec.get("recordType")
record_type = "conversation" if declared_type == "conversation" else "qa"
if record_type == "conversation":
messages = normalize_conversation_messages(rec.get("messages"))
return _ordered(
{
"schemaVersion": SCHEMA_VERSION,
"_source": "contribution",
"_ts": server_ts_ms,
"_dedup_key": f"{dedup_base}:conversation",
"conversationId": None,
"feedbackId": None,
"recordType": "conversation",
"answerIndex": None,
"action": "rate",
"prevFeedbackId": None,
"editCount": 0,
"status": "active",
"trainingStatus": training_status,
"ratingValue": None,
"ratingSlug": None,
"ratingTitle": None,
"ratingMode": None,
"message": _bounded_text(
rec.get("message"), limit=_MAX_CONTRIBUTION_NOTE_CHARS
),
"query": "",
"answer": "",
"messages": messages,
"model": None,
"modelEvidence": (
"client_reported_per_message"
if any(
isinstance(m.get("model"), dict)
for m in messages
if m.get("role") == "assistant"
)
else None
),
"page": envelope.get("page") or "",
"consentVersion": _resolve_consent_version(
envelope.get("consentVersion")
),
"ts": rec.get("ts"),
}
)
answer_index = rec.get("answerIndex")
try:
answer_index = int(answer_index) if answer_index is not None else None
except (TypeError, ValueError):
answer_index = None
rating_fields = normalize_rating(
rec.get("ratingValue"),
rec.get("ratingLabel"),
rating_mode=rec.get("ratingMode"),
rating_title=rec.get("ratingTitle"),
feedback_id=None,
)
return _ordered(
{
"schemaVersion": SCHEMA_VERSION,
"_source": "contribution",
"_ts": server_ts_ms,
"_dedup_key": f"{dedup_base}:{answer_index}",
"conversationId": None,
"feedbackId": None,
"recordType": "qa",
"answerIndex": answer_index,
"action": "rate",
"prevFeedbackId": None,
"editCount": 0,
"status": "active",
"trainingStatus": training_status,
"ratingValue": rec.get("ratingValue"),
"ratingSlug": rating_fields["ratingSlug"],
"ratingTitle": rating_fields["ratingTitle"],
"ratingMode": rating_fields["ratingMode"],
"message": _bounded_text(
rec.get("message"), limit=_MAX_CONTRIBUTION_NOTE_CHARS
),
"query": _bounded_text(
rec.get("query"), limit=_MAX_CONVERSATION_MESSAGE_CHARS
),
"answer": _bounded_text(
rec.get("answer"), limit=_MAX_CONVERSATION_MESSAGE_CHARS
),
"messages": None,
"model": normalize_model(envelope.get("model")),
"modelEvidence": "client_reported" if envelope.get("model") else None,
"page": envelope.get("page") or "",
"consentVersion": _resolve_consent_version(envelope.get("consentVersion")),
"ts": rec.get("ts"),
}
)
def normalize_contribution_withdrawal_record(
dedup_key: str,
*,
server_ts_ms: int,
) -> dict[str, Any]:
"""Create a privacy-minimal contribution withdrawal tombstone.
The tombstone carries no original question, answer, note, page, model, or
participant identifier. It only repeats the server-owned contribution
deduplication key so the training builder can suppress an earlier eligible
row by last-write-wins. This is a *training withdrawal* signal; it is not
proof that append-only Git/provider history was physically erased.
"""
key = _safe_id(dedup_key)
if not key:
raise ValueError("A valid contribution deduplication key is required.")
answer_index = None
try: # ruff: ignore[suppressible-exception]
answer_index = int(key.rsplit(":", 1)[1])
except (IndexError, TypeError, ValueError):
pass
return _ordered(
{
"schemaVersion": SCHEMA_VERSION,
"_source": "contribution",
"_ts": server_ts_ms,
"_dedup_key": key,
"conversationId": None,
"feedbackId": None,
"recordType": None,
"answerIndex": answer_index,
"action": "withdraw",
"prevFeedbackId": None,
"editCount": 0,
"status": "withdrawn",
"trainingStatus": "withdrawn",
"ratingValue": None,
"ratingSlug": None,
"ratingTitle": None,
"ratingMode": None,
"message": "",
"query": "",
"answer": "",
"model": None,
"modelEvidence": None,
"page": "",
"consentVersion": None,
"ts": None,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# Back-compat normalisation for old records
# ─────────────────────────────────────────────────────────────────────────────
def normalize_record(raw: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
"""Normalise any stored JSONL record (old or new) to the canonical schema.
Handles records written before the schema fix by detecting and mapping
legacy field names (``_sessionId``, ``_page``, ``_model``, ``_consentVersion``,
``rating``) to their canonical equivalents.
Parameters
----------
raw : dict
A single record dict as loaded from a JSONL file.
Returns
-------
dict
Canonical record. Idempotent: already-canonical records pass through
unchanged.
Notes
-----
Developer note β€” Priority
For any field that has both an old and a new name present in the same
raw record, the new canonical name takes precedence.
Examples
--------
>>> old_contribution = {"_sessionId": "abc", "_page": "http://...", ...}
>>> new_contribution = normalize_record(old_contribution)
>>> "conversationId" in new_contribution
True
>>> "_sessionId" not in new_contribution
True
"""
source: str = raw.get("_source", "")
out: dict[str, Any] = dict(raw)
# ── Map legacy contribution field names β†’ canonical ───────────────────────
if "_sessionId" in out and "conversationId" not in out:
out["conversationId"] = out.pop("_sessionId")
elif "_sessionId" in out:
out.pop("_sessionId") # canonical name already present; drop alias
if "_page" in out and "page" not in out:
out["page"] = out.pop("_page")
elif "_page" in out:
out.pop("_page")
if "_model" in out and "model" not in out:
out["model"] = out.pop("_model")
elif "_model" in out:
out.pop("_model")
if "_consentVersion" in out and "consentVersion" not in out:
out["consentVersion"] = out.pop("_consentVersion")
elif "_consentVersion" in out:
out.pop("_consentVersion")
# ── Map legacy feedback field names β†’ canonical ───────────────────────────
# sessionId in feedback was the per-submission idempotency key (now feedbackId).
# Do NOT rename for contribution records (contributions have no sessionId field).
if source == "feedback":
if "sessionId" in out and "feedbackId" not in out:
out["feedbackId"] = out.pop("sessionId")
elif "sessionId" in out:
out.pop("sessionId")
# prevSessionId in retract records β†’ prevFeedbackId.
if "prevSessionId" in out and "prevFeedbackId" not in out:
out["prevFeedbackId"] = out.pop("prevSessionId")
elif "prevSessionId" in out:
out.pop("prevSessionId")
# ── Drop legacy aliases ───────────────────────────────────────────────────
# ``rating`` was always == ``ratingLabel``; it provides no additional info.
out.pop("rating", None)
# ── Back-fill missing canonical fields (schemaVersion: 1 β†’ 2) ─────────────
out["schemaVersion"] = SCHEMA_VERSION
out.setdefault("feedbackId", None)
out.setdefault("recordType", "qa" if source == "contribution" else None)
out.setdefault("action", "rate")
out.setdefault("prevFeedbackId", None)
# editCount: None for retraction tombstones (not applicable), 0 for any
# pre-v2 "rate" record that predates this column.
out.setdefault("editCount", None if out.get("action") == "retract" else 0)
out.setdefault("status", "active")
out.setdefault(
"trainingStatus",
"legacy_unreviewed" if source == "contribution" else "telemetry",
)
out.setdefault("message", "")
out.setdefault("query", "")
out.setdefault("answer", "")
out.setdefault("messages", None)
out.setdefault("page", "")
out.setdefault("modelEvidence", "legacy_unverified" if out.get("model") else None)
# ── consentVersion is normalized through the current version policy. ─────
out["consentVersion"] = _resolve_consent_version(out.get("consentVersion"))
# ── Defensive re-coercion of identifier/count fields on legacy rows ───────
# Idempotent for already-canonical rows; guards against malformed legacy
# data (e.g. non-string IDs) reaching the DataFrame.
out["conversationId"] = _safe_id(out.get("conversationId"))
out["feedbackId"] = _safe_id(out.get("feedbackId"))
out["prevFeedbackId"] = _safe_id(out.get("prevFeedbackId"))
if out.get("action") != "retract":
out["editCount"] = _safe_int(out.get("editCount"), default=0)
# ── Normalise model shape ─────────────────────────────────────────────────
raw_model = out.get("model")
if isinstance(raw_model, dict):
out["model"] = normalize_model(raw_model)
# ── Normalise rating fields ───────────────────────────────────────────────
# For old records that don't yet have ratingSlug/ratingTitle/ratingMode.
if "ratingSlug" not in out:
rf = normalize_rating(
out.get("ratingValue"),
out.get("ratingLabel"),
rating_mode=out.get("ratingMode"),
rating_title=out.get("ratingTitle"),
feedback_id=out.get("feedbackId"),
)
out["ratingSlug"] = rf["ratingSlug"]
out["ratingTitle"] = rf["ratingTitle"]
out["ratingMode"] = rf["ratingMode"]
# Keep ratingLabel in sync with ratingSlug for backward compat readers.
if out.get("ratingSlug") and not out.get("ratingLabel"):
out["ratingLabel"] = out["ratingSlug"]
return _ordered(out)
# ─────────────────────────────────────────────────────────────────────────────
# I/O helpers
# ─────────────────────────────────────────────────────────────────────────────
def load_jsonl_file(path: str | Path) -> list[dict[str, Any]]:
"""Load and normalise all records from a single JSONL file.
Parameters
----------
path : str or Path
Path to a ``.jsonl`` file (one JSON object per line; blank lines and
comment lines starting with ``#`` are skipped).
Returns
-------
list of dict
Normalised records. Malformed lines are skipped with a
WARNING-level log record.
Notes
-----
User note
Both ``feedback/TIMESTAMP.jsonl`` and ``contributions/TIMESTAMP.jsonl``
files are valid inputs; the normalisation step handles the field-name
differences transparently.
"""
records: list[dict[str, Any]] = []
path = Path(path)
with path.open(encoding="utf-8") as fh:
for line_no, line in enumerate(fh, 1):
line = line.strip() # noqa: PLW2901
if not line or line.startswith("#"):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
logger.warning(
"%s:%d: JSON decode error β€” %s",
path,
line_no,
exc,
)
continue
if not isinstance(obj, dict):
logger.warning(
"%s:%d: expected JSON object, got %s β€” skipped",
path,
line_no,
type(obj).__name__,
)
continue
records.append(normalize_record(obj))
return records
def load_dataset(
feedback_dir: str | Path | None = None,
contributions_dir: str | Path | None = None,
*,
sort_by: str = "_ts",
ascending: bool = True,
) -> Any: # -> pd.DataFrame
"""Load and combine feedback and contribution records into one pandas DataFrame.
Parameters
----------
feedback_dir : str, Path, or None
Directory containing ``feedback/*.jsonl`` files, or a single
``feedback.jsonl`` file. Skipped when ``None``.
contributions_dir : str, Path, or None
Directory containing ``contributions/*.jsonl`` files, or a single
``contributions.jsonl`` file. Skipped when ``None``.
sort_by : str, optional
Column to sort the combined DataFrame by. Default ``"_ts"`` (server
receive time, ascending).
ascending : bool, optional
Sort direction. Default ``True``.
Returns
-------
pandas.DataFrame
Combined, normalised DataFrame with columns in ``CANONICAL_COLUMNS``
order. ``model`` column contains dict values (or ``NaN`` for rows with
no model info). Flat helper columns ``model_id``, ``model_provider``,
and ``model_name`` are appended for easy querying.
Raises
------
ImportError
When ``pandas`` is not installed.
Notes
-----
User note β€” one-liner::
df = load_dataset("feedback/", "contributions/")
df.groupby("_source")["ratingValue"].mean()
User note β€” filtering retractions::
active = df[df["action"] != "retract"].copy()
User note β€” dedup (prefer contribution over feedback)::
df_deduped = df.sort_values(
["_dedup_key", "_source"], ascending=[True, True]
).drop_duplicates(subset=["_dedup_key"], keep="last")
Developer note β€” model column
The ``model`` column holds Python dicts (or ``None`` β†’ pandas ``NaN``).
For JSON-serialisable storage use
``df["model"] = df["model"].apply(json.dumps)``.
Examples
--------
>>> df = load_dataset("feedback/", "contributions/")
>>> df.dtypes["ratingValue"]
dtype('object')
>>> df.dtypes["_ts"]
dtype('int64')
"""
try:
import pandas as pd # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"pandas is required for load_dataset(). "
"Install it with: pip install pandas"
) from exc
all_records: list[dict[str, Any]] = []
def _collect(directory: str | Path) -> None:
p = Path(directory)
if p.is_file():
all_records.extend(load_jsonl_file(p))
elif p.is_dir():
for jsonl_file in sorted(p.glob("*.jsonl")):
all_records.extend(load_jsonl_file(jsonl_file))
if feedback_dir is not None:
_collect(feedback_dir)
if contributions_dir is not None:
_collect(contributions_dir)
if not all_records:
# Return empty DataFrame with correct columns and dtypes.
return pd.DataFrame(columns=CANONICAL_COLUMNS)
df = pd.DataFrame(all_records)
# ── Ensure all canonical columns are present (back-compat) ────────────────
for col in CANONICAL_COLUMNS:
if col not in df.columns:
df[col] = None
# ── Reorder columns to canonical order ────────────────────────────────────
extra_cols = [c for c in df.columns if c not in CANONICAL_COLUMNS]
df = df[CANONICAL_COLUMNS + extra_cols]
# ── Flat model helper columns for easy querying ───────────────────────────
def _model_field(m: Any, key: str) -> Any:
if isinstance(m, dict):
return m.get(key)
return None
df["model_id"] = df["model"].apply(_model_field, key="id")
df["model_provider"] = df["model"].apply(_model_field, key="provider")
df["model_name"] = df["model"].apply(_model_field, key="model")
# ── Sort ──────────────────────────────────────────────────────────────────
if sort_by in df.columns:
df = df.sort_values(sort_by, ascending=ascending, ignore_index=True)
return df