celik-muhammed commited on
Commit
63b3990
Β·
verified Β·
1 Parent(s): 1b792a3

Upload _dataset_schema.py

Browse files
Files changed (1) hide show
  1. _dataset_schema.py +322 -45
_dataset_schema.py CHANGED
@@ -1,10 +1,10 @@
1
- # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/dataset_schema.py
2
  #
3
  # Authors: The scikit-plots developers
4
  # SPDX-License-Identifier: BSD-3-Clause
5
  #
6
  # Canonical schema and record-normalization for the AI-assistant dataset.
7
- # Version: 1.0.0
8
 
9
  """
10
  Canonical schema, normalization, and pandas loading for the AI-assistant dataset.
@@ -36,8 +36,8 @@ Canonical key order (identical in every row)
36
  schemaVersion
37
  _source _ts _dedup_key
38
  conversationId feedbackId
39
- answerIndex action prevFeedbackId status
40
- ratingValue ratingSlug ratingTitle ratingMode message
41
  query answer
42
  model
43
  page consentVersion
@@ -48,7 +48,7 @@ Notes
48
  User note
49
  Load the full dataset in one line::
50
 
51
- from dataset_schema import load_dataset
52
  df = load_dataset("feedback/", "contributions/")
53
 
54
  Developer note β€” Rating vocabulary
@@ -59,21 +59,80 @@ Developer note β€” Rating vocabulary
59
  derived to populate ``ratingSlug`` / ``ratingLabel``.
60
 
61
  Developer note β€” Model shape
62
- Feedback records used ``model: {id, provider, model}`` (3 keys).
63
- Contribution records used ``_model: {default, id, label, provider, model,
64
- endpoint, info_url, description}`` (8 keys). Both are now written as
65
- ``model`` with all 8 keys present; missing keys are ``None``.
66
-
67
- Developer note β€” Retraction records
 
 
 
 
68
  A retraction payload has ``action="retract"`` and ``prevSessionId`` pointing
69
  to the ``sessionId`` (= ``feedbackId``) of the record being invalidated.
70
  The normalised form uses ``prevFeedbackId`` for clarity and fills all rating /
71
  content / model fields with ``None``.
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  References
74
  ----------
75
- See ``DATASET_COLLECTION_GUIDANCE.md`` for the deduplication contract and
76
- the training-pipeline usage of ``_source``, ``_dedup_key``, and ``status``.
 
77
  """
78
 
79
  from __future__ import annotations
@@ -89,8 +148,11 @@ from typing import Any
89
  # ─────────────────────────────────────────────────────────────────────────────
90
 
91
  #: Current schema version for records written by this module.
92
- #: Increment when a breaking field-name change is introduced.
93
- SCHEMA_VERSION: int = 1
 
 
 
94
 
95
  #: Ordered list of canonical column names. Every stored JSONL row and every
96
  #: row in the pandas DataFrame will have these columns in exactly this order.
@@ -103,11 +165,20 @@ CANONICAL_COLUMNS: list[str] = [
103
  "_dedup_key", # "{conversationId}:{answerIndex}"
104
  # ── Session identity ──────────────────────────────────────────────────────
105
  "conversationId", # stable per-page-load chat session UUID
106
- "feedbackId", # per-feedback-event idempotency key (None for contributions)
 
 
107
  # ── Record descriptor ─────────────────────────────────────────────────────
108
  "answerIndex", # 0-based position of answer in the conversation
109
  "action", # "rate" | "retract"
110
- "prevFeedbackId", # for retracts: feedbackId of the record being invalidated
 
 
 
 
 
 
 
111
  "status", # "active" | "retracted" (dedup pipeline manages)
112
  # ── Rating ────────────────────────────────────────────────────────────────
113
  "ratingValue", # int | None: numeric score (-5..+5 for panel; -1|+1 for quick)
@@ -122,7 +193,8 @@ CANONICAL_COLUMNS: list[str] = [
122
  "model", # dict | None: normalised 8-key model object (see MODEL_KEYS)
123
  # ── Context ───────────────────────────────────────────────────────────────
124
  "page", # str: documentation page URL
125
- "consentVersion", # str | None: "v1.0" for contributions; None for feedback
 
126
  # ── Timestamps ────────────��──────────────────────────────────────────────
127
  "ts", # int: client-side event time, ms since epoch
128
  ]
@@ -141,6 +213,161 @@ MODEL_KEYS: list[str] = [
141
  "default", # bool | None: True when this is the default model in the config
142
  ]
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  # ── Rating vocabulary ─────────────────────────────────────────────────────────
145
  # The panel feedback 11-point scale. ``value`` here is the slug stored as
146
  # ``ratingLabel`` in the JS source (_FEEDBACK_DEFAULTS[idx].value).
@@ -425,12 +652,23 @@ def normalize_feedback_record(
425
  arbitrary client-supplied fields directly into the dataset. This
426
  implementation whitelists only the known fields from the JS schema,
427
  discarding unexpected keys. The legacy ``rating`` alias is dropped
428
- (its value was always identical to ``ratingLabel``).
429
-
430
- Developer note β€” Retract records
431
- When ``payload["action"] == "retract"`` the rating / content / model
432
- fields are filled with ``None``; ``prevFeedbackId`` is set from
433
- ``payload["prevSessionId"]``.
 
 
 
 
 
 
 
 
 
 
 
434
 
435
  Developer note β€” ratingLabel normalization
436
  Old quick-feedback records set ``ratingLabel = opt.title`` (Title Case:
@@ -447,18 +685,24 @@ def normalize_feedback_record(
447
  is_retract: bool = payload.get("action") == "retract"
448
 
449
  # ── Identity ──────────────────────────────────────────────────────────────
450
- conversation_id: str = payload.get("conversationId") or ""
451
  # Feedback sessionId is the per-submission idempotency key, renamed to
452
  # feedbackId to distinguish it from the chat-session conversationId.
453
- feedback_id: str | None = payload.get("sessionId") or None
454
  answer_index: int | None = payload.get("answerIndex")
455
 
456
- # ── Retract-specific fields ───────────────────────────────────────────────
457
- # prevSessionId in the retract payload points to the sessionId (= feedbackId)
458
- # of the original record being invalidated.
459
- prev_feedback_id: str | None = (
460
- payload.get("prevSessionId") or None if is_retract else None
461
- )
 
 
 
 
 
 
462
 
463
  # ── Rating (None for retracts) ────────────────────────────────────────────
464
  if is_retract:
@@ -476,7 +720,8 @@ def normalize_feedback_record(
476
  feedback_id=feedback_id,
477
  )
478
 
479
- # ── Model (None for quick feedback and retracts) ──────────────────────────
 
480
  raw_model: dict | None = payload.get("model")
481
  if isinstance(raw_model, str):
482
  # Guard: old or malformed payloads sometimes send model as a bare string.
@@ -487,12 +732,13 @@ def normalize_feedback_record(
487
  "schemaVersion": int(payload.get("schemaVersion") or SCHEMA_VERSION),
488
  "_source": "feedback",
489
  "_ts": server_ts_ms,
490
- "_dedup_key": f"{conversation_id}:{answer_index}",
491
- "conversationId": conversation_id or None,
492
  "feedbackId": feedback_id,
493
  "answerIndex": int(answer_index) if answer_index is not None else None,
494
  "action": "retract" if is_retract else "rate",
495
  "prevFeedbackId": prev_feedback_id,
 
496
  "status": "active",
497
  "ratingValue": None if is_retract else payload.get("ratingValue"),
498
  "ratingSlug": rating_fields["ratingSlug"],
@@ -503,7 +749,7 @@ def normalize_feedback_record(
503
  "answer": "" if is_retract else (payload.get("answer") or ""),
504
  "model": None if is_retract else normalize_model(raw_model),
505
  "page": "" if is_retract else (payload.get("page") or ""),
506
- "consentVersion": None, # feedback is implicit consent; no version stored
507
  "ts": payload.get("ts"),
508
  })
509
 
@@ -544,6 +790,15 @@ def normalize_contribution_record(
544
  because they are universal provenance fields managed exclusively by
545
  the server.
546
 
 
 
 
 
 
 
 
 
 
547
  Developer note β€” ``_ts`` consistency
548
  All rows in a single contribute batch share the same ``server_ts_ms``
549
  value. The previous inline ``int(_time.time() * 1000)`` inside a
@@ -558,7 +813,7 @@ def normalize_contribution_record(
558
  """
559
  # conversation_id is the JS _sessionId (stable per-page-load chat session UUID).
560
  # The envelope calls it "sessionId" (without underscore); we rename to conversationId.
561
- conversation_id: str = envelope.get("sessionId") or ""
562
  answer_index: int | None = rec.get("answerIndex")
563
 
564
  rating_fields = normalize_rating(
@@ -566,19 +821,25 @@ def normalize_contribution_record(
566
  rec.get("ratingLabel"),
567
  rating_mode=rec.get("ratingMode"), # from _feedbackStore.ratingMode (new JS)
568
  rating_title=rec.get("ratingTitle"), # from _feedbackStore.ratingTitle (new JS)
569
- feedback_id=None, # no per-event session ID for contributions
570
  )
571
 
572
  return _ordered({
573
  "schemaVersion": int(envelope.get("schemaVersion") or SCHEMA_VERSION),
574
  "_source": "contribution",
575
  "_ts": server_ts_ms,
576
- "_dedup_key": f"{conversation_id}:{answer_index}",
577
- "conversationId": conversation_id or None,
578
- "feedbackId": None, # contributions are batch-level; no per-event ID
 
 
 
579
  "answerIndex": int(answer_index) if answer_index is not None else None,
580
  "action": "rate",
581
- "prevFeedbackId": None,
 
 
 
582
  "status": "active",
583
  "ratingValue": rec.get("ratingValue"),
584
  "ratingSlug": rating_fields["ratingSlug"],
@@ -589,7 +850,7 @@ def normalize_contribution_record(
589
  "answer": rec.get("answer") or "",
590
  "model": normalize_model(envelope.get("model")),
591
  "page": envelope.get("page") or "",
592
- "consentVersion": envelope.get("consentVersion"),
593
  "ts": rec.get("ts"),
594
  })
595
 
@@ -675,18 +936,34 @@ def normalize_record(raw: dict[str, Any]) -> dict[str, Any]:
675
  # ``rating`` was always == ``ratingLabel``; it provides no additional info.
676
  out.pop("rating", None)
677
 
678
- # ── Back-fill missing canonical fields ────────────────────────────────────
679
  out.setdefault("schemaVersion", SCHEMA_VERSION)
680
  out.setdefault("feedbackId", None)
681
  out.setdefault("action", "rate")
682
  out.setdefault("prevFeedbackId", None)
 
 
 
683
  out.setdefault("status", "active")
684
- out.setdefault("consentVersion", None)
685
  out.setdefault("message", "")
686
  out.setdefault("query", "")
687
  out.setdefault("answer", "")
688
  out.setdefault("page", "")
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  # ── Normalise model shape ─────────────────────────────────────────────────
691
  raw_model = out.get("model")
692
  if isinstance(raw_model, dict):
 
1
+ # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/_dataset_schema.py
2
  #
3
  # Authors: The scikit-plots developers
4
  # SPDX-License-Identifier: BSD-3-Clause
5
  #
6
  # Canonical schema and record-normalization for the AI-assistant dataset.
7
+ # Version: 2.0.0 (see "Schema version history" in the module docstring)
8
 
9
  """
10
  Canonical schema, normalization, and pandas loading for the AI-assistant dataset.
 
36
  schemaVersion
37
  _source _ts _dedup_key
38
  conversationId feedbackId
39
+ answerIndex action prevFeedbackId editCount status
40
+ ratingValue ratingSlug ratingTitle ratingMode message
41
  query answer
42
  model
43
  page consentVersion
 
48
  User note
49
  Load the full dataset in one line::
50
 
51
+ from _dataset_schema import load_dataset
52
  df = load_dataset("feedback/", "contributions/")
53
 
54
  Developer note β€” Rating vocabulary
 
59
  derived to populate ``ratingSlug`` / ``ratingLabel``.
60
 
61
  Developer note β€” Model shape
62
+ Both feedback and contribution payloads now build the model object via the
63
+ shared client-side ``_buildModelInfo(cfg)`` helper, so every record that
64
+ carries model attribution has the **same 8-key shape** (see
65
+ :data:`MODEL_KEYS`): ``id, provider, model, label, endpoint, info_url,
66
+ description, default``. :func:`normalize_model` still projects *any*
67
+ input dict (including pre-v2 3-key ``{id, provider, model}`` records) onto
68
+ this 8-key shape for backward compatibility β€” missing keys become
69
+ ``None``.
70
+
71
+ Developer note β€” Retraction records (``action="retract"``)
72
  A retraction payload has ``action="retract"`` and ``prevSessionId`` pointing
73
  to the ``sessionId`` (= ``feedbackId``) of the record being invalidated.
74
  The normalised form uses ``prevFeedbackId`` for clarity and fills all rating /
75
  content / model fields with ``None``.
76
 
77
+ Developer note β€” Supersession chains (``action="rate"`` + ``prevFeedbackId``)
78
+ When a user edits a previously submitted rating, the **new** ``rate``
79
+ record now also carries ``prevFeedbackId`` = the ``feedbackId`` of the
80
+ rating it replaces (in addition to the separate ``retract`` tombstone for
81
+ the old record). This gives downstream tooling a direct, walkable edit
82
+ history per ``(conversationId, answerIndex)`` without having to infer
83
+ chains purely from ``_ts`` ordering. ``editCount`` is a monotonically
84
+ increasing counter (``0`` for the first rating, ``+1`` per edit) carried
85
+ alongside ``prevFeedbackId`` for quick "rating churn" analysis without
86
+ walking the chain.
87
+
88
+ Developer note β€” ``feedbackId`` cross-source linkage
89
+ Contribution records now carry ``feedbackId`` = the ``feedbackId`` of the
90
+ per-answer feedback event that was active when the user clicked
91
+ "Contribute" (``None`` when the user never rated that answer
92
+ individually). This is a **direct foreign key** between a
93
+ ``contributions/`` row and a ``feedback/`` row β€” in addition to the
94
+ coarser ``_dedup_key`` (``"{conversationId}:{answerIndex}"``) β€” and is the
95
+ preferred join key for ``deduplicate_dataset.py`` going forward.
96
+
97
+ Developer note β€” ``consentVersion`` (reserved)
98
+ Consent-version tracking is **not currently enforced**.
99
+ :data:`CONSENT_VERSION_ENABLED` is ``False``, so :func:`normalize_record`,
100
+ :func:`normalize_feedback_record`, and :func:`normalize_contribution_record`
101
+ all write ``consentVersion: null`` regardless of what the client sends β€”
102
+ including historical records that stored ``"v1.0"``. This keeps every row
103
+ in the combined DataFrame consistent. See :data:`RESERVED_CONSENT_VERSION`
104
+ for the value to adopt when this feature is implemented.
105
+
106
+ Schema version history
107
+ -----------------------
108
+ ``schemaVersion: 1`` (initial canonical schema)
109
+ ``feedbackId`` / ``prevFeedbackId`` always ``None`` for contribution
110
+ records; ``prevFeedbackId`` only set on ``action="retract"`` feedback
111
+ records; ``model`` may be a 3-key ``{id, provider, model}`` dict (quick
112
+ feedback) or 8-key dict (panel/contribution); ``consentVersion`` may be
113
+ ``"v1.0"`` on contribution records; no ``editCount`` column.
114
+
115
+ ``schemaVersion: 2`` (this version) β€” additive, backward compatible
116
+ * ``feedbackId`` populated on contribution records when the answer was
117
+ individually rated before contributing (see "feedbackId cross-source
118
+ linkage" above).
119
+ * ``prevFeedbackId`` populated on ``action="rate"`` records (both sources)
120
+ when the rating supersedes a prior one (see "Supersession chains" above).
121
+ * New ``editCount`` column (``int``, default ``0``).
122
+ * ``model`` is always the full 8-key shape when present (see "Model shape"
123
+ above); old 3-key records are still readable via :func:`normalize_model`.
124
+ * ``consentVersion`` is always ``None`` (see "consentVersion (reserved)"
125
+ above); old ``"v1.0"`` values are normalised away on read.
126
+
127
+ :func:`normalize_record` reads ``schemaVersion: 1`` rows transparently β€”
128
+ all v2-only fields default via ``setdefault`` (``feedbackId=None``,
129
+ ``prevFeedbackId=None``, ``editCount=0``).
130
+
131
  References
132
  ----------
133
+ See ``DATASET_COLLECTION_GUIDANCE.md`` for the deduplication contract, the
134
+ ``feedbackId`` / ``prevFeedbackId`` supersession-chain resolution algorithm,
135
+ and the training-pipeline usage of ``_source``, ``_dedup_key``, and ``status``.
136
  """
137
 
138
  from __future__ import annotations
 
148
  # ─────────────────────────────────────────────────────────────────────────────
149
 
150
  #: Current schema version for records written by this module.
151
+ #: Increment when a breaking field-name change is introduced; additive
152
+ #: changes (new optional columns, wider population of existing columns) bump
153
+ #: this too so consumers can branch on ``schemaVersion`` to know which fields
154
+ #: to expect. See "Schema version history" above for what changed in v2.
155
+ SCHEMA_VERSION: int = 2
156
 
157
  #: Ordered list of canonical column names. Every stored JSONL row and every
158
  #: row in the pandas DataFrame will have these columns in exactly this order.
 
165
  "_dedup_key", # "{conversationId}:{answerIndex}"
166
  # ── Session identity ──────────────────────────────────────────────────────
167
  "conversationId", # stable per-page-load chat session UUID
168
+ "feedbackId", # per-feedback-event id. For contributions: the feedbackId
169
+ # of the matching per-answer feedback event, or None if
170
+ # the user never rated this answer individually.
171
  # ── Record descriptor ─────────────────────────────────────────────────────
172
  "answerIndex", # 0-based position of answer in the conversation
173
  "action", # "rate" | "retract"
174
+ "prevFeedbackId", # feedbackId of the record this one supersedes/invalidates.
175
+ # action="rate": set when this rating replaces an earlier
176
+ # one for the same answerIndex (an edit).
177
+ # action="retract": set to the feedbackId being retracted.
178
+ # None for a first-time rating.
179
+ "editCount", # int: 0 for the first rating; +1 each time the user
180
+ # edits/re-rates the same answer (mirrors prevFeedbackId
181
+ # chain length without walking it). None for retracts.
182
  "status", # "active" | "retracted" (dedup pipeline manages)
183
  # ── Rating ────────────────────────────────────────────────────────────────
184
  "ratingValue", # int | None: numeric score (-5..+5 for panel; -1|+1 for quick)
 
193
  "model", # dict | None: normalised 8-key model object (see MODEL_KEYS)
194
  # ── Context ───────────────────────────────────────────────────────────────
195
  "page", # str: documentation page URL
196
+ "consentVersion", # str | None: reserved for future use β€” always None while
197
+ # CONSENT_VERSION_ENABLED is False (see below)
198
  # ── Timestamps ────────────��──────────────────────────────────────────────
199
  "ts", # int: client-side event time, ms since epoch
200
  ]
 
213
  "default", # bool | None: True when this is the default model in the config
214
  ]
215
 
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+ # Consent-version handling (reserved for future use)
218
+ # ─────────────────────────────────────────────────────────────────────────────
219
+
220
+ #: Master switch for consent-version tracking. While ``False`` (current
221
+ #: state), every normaliser writes ``consentVersion: null`` regardless of what
222
+ #: the client sent β€” including historical contribution records that stored
223
+ #: ``"v1.0"`` β€” so the column is uniformly ``None`` across the whole dataset.
224
+ #:
225
+ #: To activate consent-version tracking in the future:
226
+ #: 1. Set this to ``True``.
227
+ #: 2. Set :data:`RESERVED_CONSENT_VERSION` to the real version string
228
+ #: (e.g. keep ``"1.0.0"``, or bump it).
229
+ #: 3. In ``ai-assistant.js``, uncomment the ``CONSENT_VERSION`` constant and
230
+ #' change ``consentVersion: null`` back to ``consentVersion: CONSENT_VERSION``
231
+ #: in the ``/v1/contribute`` payload (see the matching comment there).
232
+ CONSENT_VERSION_ENABLED: bool = False
233
+
234
+ #: Semantic version string reserved for the consent-banner copy/flow, for use
235
+ #: once :data:`CONSENT_VERSION_ENABLED` is flipped to ``True``. Bump this
236
+ #: whenever consent terms change materially. Currently unused.
237
+ RESERVED_CONSENT_VERSION: str = "1.0.0"
238
+
239
+
240
+ def _resolve_consent_version(raw: Any) -> str | None:
241
+ """Resolve the ``consentVersion`` field for a normalised record.
242
+
243
+ Parameters
244
+ ----------
245
+ raw : Any
246
+ The raw ``consentVersion``-like value from the payload or a
247
+ previously stored record (feedback payloads never had one;
248
+ contribution envelopes/records may carry ``"v1.0"`` or ``null``).
249
+
250
+ Returns
251
+ -------
252
+ str or None
253
+ ``None`` while :data:`CONSENT_VERSION_ENABLED` is ``False`` (current
254
+ behaviour) β€” *regardless* of ``raw``, so historical ``"v1.0"`` values
255
+ are normalised away too. Once enabled, ``raw`` is passed through
256
+ unchanged if it is a non-empty string, else ``None`` (this function
257
+ never *invents* a consent version for a record that did not declare
258
+ one β€” :data:`RESERVED_CONSENT_VERSION` is purely documentation for
259
+ what the JS widget should send once re-enabled).
260
+
261
+ Notes
262
+ -----
263
+ Developer note
264
+ Centralising this here means flipping :data:`CONSENT_VERSION_ENABLED`
265
+ is the *only* code change needed in this module; both normalisers and
266
+ :func:`normalize_record` already call this function.
267
+
268
+ Examples
269
+ --------
270
+ >>> _resolve_consent_version("v1.0") # CONSENT_VERSION_ENABLED=False
271
+ >>> _resolve_consent_version(None)
272
+ """
273
+ if not CONSENT_VERSION_ENABLED:
274
+ return None
275
+ return raw if isinstance(raw, str) and raw else None
276
+
277
+
278
+ # ─────────────────────────────────────────────────────────────────────────────
279
+ # Defensive ID coercion
280
+ # ─────────────────────────────────────────────────────────────────────────────
281
+
282
+ #: Hard upper bound on stored identifier strings (``feedbackId``,
283
+ #: ``prevFeedbackId``, ``conversationId``). Generated values (UUIDs, or the
284
+ #: ``"{uuid}-quick-{idx}-{ts}"`` composite) are well under 100 chars; 256
285
+ #: leaves generous headroom while bounding worst-case row size if a malformed
286
+ #: or malicious client sends an oversized string.
287
+ _MAX_ID_LEN: int = 256
288
+
289
+
290
+ def _safe_id(value: Any) -> str | None:
291
+ """Coerce a client-supplied identifier to a bounded ``str`` or ``None``.
292
+
293
+ Parameters
294
+ ----------
295
+ value : Any
296
+ Raw value from the client payload (expected: ``str`` or ``None``/
297
+ absent). Any non-string (e.g. an accidental ``int``, ``list``, or
298
+ ``dict`` from a malformed client) is treated as absent.
299
+
300
+ Returns
301
+ -------
302
+ str or None
303
+ ``None`` for falsy/non-string input. Otherwise the string,
304
+ truncated to :data:`_MAX_ID_LEN` characters.
305
+
306
+ Notes
307
+ -----
308
+ Developer note β€” Security
309
+ Applied to every ``*FeedbackId`` / ``conversationId`` field written by
310
+ the normalisers. Prevents a malformed or adversarial payload (wrong
311
+ type, or a multi-MB string) from being written verbatim into the
312
+ dataset. Truncation is preferred over rejection so a single bad field
313
+ does not fail an otherwise-valid submission β€” see Principle 2 (no
314
+ silent failures): truncation is itself loud in the sense that a
315
+ truncated UUID will simply never match anything in
316
+ ``deduplicate_dataset.py``'s join logic, which is the correct,
317
+ self-healing outcome for a corrupted ID.
318
+
319
+ Examples
320
+ --------
321
+ >>> _safe_id("57b73883-ba14-4a0c-ac38-79bc76a2c0ee")
322
+ '57b73883-ba14-4a0c-ac38-79bc76a2c0ee'
323
+ >>> _safe_id(None)
324
+ >>> _safe_id(12345)
325
+ >>> _safe_id("x" * 300)[-1] == "x" and len(_safe_id("x" * 300)) == 256
326
+ True
327
+ """
328
+ if not isinstance(value, str) or not value:
329
+ return None
330
+ return value[:_MAX_ID_LEN]
331
+
332
+
333
+ def _safe_int(value: Any, default: int = 0) -> int:
334
+ """Coerce a client-supplied count to a non-negative ``int``.
335
+
336
+ Parameters
337
+ ----------
338
+ value : Any
339
+ Raw value (expected: small non-negative ``int``). ``bool`` is
340
+ rejected even though ``bool`` is a subclass of ``int`` in Python,
341
+ since a stray ``True``/``False`` here indicates a client bug, not a
342
+ real edit count.
343
+ default : int, optional
344
+ Value returned for missing/invalid input. Default ``0``.
345
+
346
+ Returns
347
+ -------
348
+ int
349
+ ``max(0, int(value))`` when ``value`` is a non-bool ``int``/``float``
350
+ representing a whole number; otherwise ``default``.
351
+
352
+ Examples
353
+ --------
354
+ >>> _safe_int(3)
355
+ 3
356
+ >>> _safe_int(-1)
357
+ 0
358
+ >>> _safe_int(None)
359
+ 0
360
+ >>> _safe_int(True)
361
+ 0
362
+ """
363
+ if isinstance(value, bool):
364
+ return default
365
+ if isinstance(value, int):
366
+ return max(0, value)
367
+ if isinstance(value, float) and value.is_integer():
368
+ return max(0, int(value))
369
+ return default
370
+
371
  # ── Rating vocabulary ─────────────────────────────────────────────────────────
372
  # The panel feedback 11-point scale. ``value`` here is the slug stored as
373
  # ``ratingLabel`` in the JS source (_FEEDBACK_DEFAULTS[idx].value).
 
652
  arbitrary client-supplied fields directly into the dataset. This
653
  implementation whitelists only the known fields from the JS schema,
654
  discarding unexpected keys. The legacy ``rating`` alias is dropped
655
+ (its value was always identical to ``ratingLabel``). All identifier
656
+ fields (``conversationId``, ``feedbackId``, ``prevFeedbackId``) are
657
+ passed through :func:`_safe_id` and ``editCount`` through
658
+ :func:`_safe_int` to bound type/size regardless of client input.
659
+
660
+ Developer note β€” Retract records (``action="retract"``)
661
+ ``prevFeedbackId`` is set from ``payload["prevSessionId"]`` β€”
662
+ the ``feedbackId`` of the record being invalidated. ``editCount`` is
663
+ ``None`` (not applicable to a tombstone).
664
+
665
+ Developer note β€” Rate records with ``prevFeedbackId`` (edits)
666
+ When the new JS sends ``payload["prevFeedbackId"]`` on an
667
+ ``action="rate"`` record, it means this rating *replaces* an earlier
668
+ one for the same ``(conversationId, answerIndex)`` β€” the value is the
669
+ ``feedbackId`` of that earlier rating (a separate ``retract``
670
+ tombstone for it is sent too). ``editCount`` is
671
+ ``payload["editCount"]`` (``0`` for a first-time rating).
672
 
673
  Developer note β€” ratingLabel normalization
674
  Old quick-feedback records set ``ratingLabel = opt.title`` (Title Case:
 
685
  is_retract: bool = payload.get("action") == "retract"
686
 
687
  # ── Identity ──────────────────────────────────────────────────────────────
688
+ conversation_id: str | None = _safe_id(payload.get("conversationId"))
689
  # Feedback sessionId is the per-submission idempotency key, renamed to
690
  # feedbackId to distinguish it from the chat-session conversationId.
691
+ feedback_id: str | None = _safe_id(payload.get("sessionId"))
692
  answer_index: int | None = payload.get("answerIndex")
693
 
694
+ # ── Supersession / edit-chain fields ──────────────────────────────────────
695
+ if is_retract:
696
+ # prevSessionId in the retract payload points to the sessionId
697
+ # (= feedbackId) of the original record being invalidated.
698
+ prev_feedback_id: str | None = _safe_id(payload.get("prevSessionId"))
699
+ edit_count: int | None = None # not applicable to a tombstone
700
+ else:
701
+ # New JS sends prevFeedbackId on a "rate" record when this rating
702
+ # replaces an earlier one (an edit) β€” see "Rate records with
703
+ # prevFeedbackId" above. None for a first-time rating.
704
+ prev_feedback_id = _safe_id(payload.get("prevFeedbackId"))
705
+ edit_count = _safe_int(payload.get("editCount"), default=0)
706
 
707
  # ── Rating (None for retracts) ────────────────────────────────────────────
708
  if is_retract:
 
720
  feedback_id=feedback_id,
721
  )
722
 
723
+ # ── Model (None for retracts; populated for both quick and panel feedback
724
+ # since _buildModelInfo(cfg) is now used uniformly on the client) ─────────
725
  raw_model: dict | None = payload.get("model")
726
  if isinstance(raw_model, str):
727
  # Guard: old or malformed payloads sometimes send model as a bare string.
 
732
  "schemaVersion": int(payload.get("schemaVersion") or SCHEMA_VERSION),
733
  "_source": "feedback",
734
  "_ts": server_ts_ms,
735
+ "_dedup_key": f"{conversation_id or ''}:{answer_index}",
736
+ "conversationId": conversation_id,
737
  "feedbackId": feedback_id,
738
  "answerIndex": int(answer_index) if answer_index is not None else None,
739
  "action": "retract" if is_retract else "rate",
740
  "prevFeedbackId": prev_feedback_id,
741
+ "editCount": edit_count,
742
  "status": "active",
743
  "ratingValue": None if is_retract else payload.get("ratingValue"),
744
  "ratingSlug": rating_fields["ratingSlug"],
 
749
  "answer": "" if is_retract else (payload.get("answer") or ""),
750
  "model": None if is_retract else normalize_model(raw_model),
751
  "page": "" if is_retract else (payload.get("page") or ""),
752
+ "consentVersion": _resolve_consent_version(None), # feedback never declares consent
753
  "ts": payload.get("ts"),
754
  })
755
 
 
790
  because they are universal provenance fields managed exclusively by
791
  the server.
792
 
793
+ Developer note β€” ``feedbackId`` / ``prevFeedbackId`` / ``editCount``
794
+ ``tRecords`` (built client-side from ``_feedbackStore``) now forward
795
+ ``feedbackId`` (the per-answer feedback event's own ``sessionId``,
796
+ if the user rated this answer individually before contributing),
797
+ ``prevFeedbackId`` (set when that feedback event was itself an edit
798
+ of an earlier one), and ``editCount``. All three pass through
799
+ :func:`_safe_id` / :func:`_safe_int`. ``feedbackId`` is ``None`` when
800
+ the user contributed without ever rating that specific answer.
801
+
802
  Developer note β€” ``_ts`` consistency
803
  All rows in a single contribute batch share the same ``server_ts_ms``
804
  value. The previous inline ``int(_time.time() * 1000)`` inside a
 
813
  """
814
  # conversation_id is the JS _sessionId (stable per-page-load chat session UUID).
815
  # The envelope calls it "sessionId" (without underscore); we rename to conversationId.
816
+ conversation_id: str | None = _safe_id(envelope.get("sessionId"))
817
  answer_index: int | None = rec.get("answerIndex")
818
 
819
  rating_fields = normalize_rating(
 
821
  rec.get("ratingLabel"),
822
  rating_mode=rec.get("ratingMode"), # from _feedbackStore.ratingMode (new JS)
823
  rating_title=rec.get("ratingTitle"), # from _feedbackStore.ratingTitle (new JS)
824
+ feedback_id=rec.get("feedbackId"), # now forwarded β€” see docstring above
825
  )
826
 
827
  return _ordered({
828
  "schemaVersion": int(envelope.get("schemaVersion") or SCHEMA_VERSION),
829
  "_source": "contribution",
830
  "_ts": server_ts_ms,
831
+ "_dedup_key": f"{conversation_id or ''}:{answer_index}",
832
+ "conversationId": conversation_id,
833
+ # feedbackId: the per-answer feedback event's own id (sessionId), when
834
+ # the user rated this answer individually before contributing. None
835
+ # when they contributed without rating this specific answer.
836
+ "feedbackId": _safe_id(rec.get("feedbackId")),
837
  "answerIndex": int(answer_index) if answer_index is not None else None,
838
  "action": "rate",
839
+ # prevFeedbackId: forwarded from the matching feedback event when that
840
+ # event was itself an edit of an earlier rating (edit chain).
841
+ "prevFeedbackId": _safe_id(rec.get("prevFeedbackId")),
842
+ "editCount": _safe_int(rec.get("editCount"), default=0),
843
  "status": "active",
844
  "ratingValue": rec.get("ratingValue"),
845
  "ratingSlug": rating_fields["ratingSlug"],
 
850
  "answer": rec.get("answer") or "",
851
  "model": normalize_model(envelope.get("model")),
852
  "page": envelope.get("page") or "",
853
+ "consentVersion": _resolve_consent_version(envelope.get("consentVersion")),
854
  "ts": rec.get("ts"),
855
  })
856
 
 
936
  # ``rating`` was always == ``ratingLabel``; it provides no additional info.
937
  out.pop("rating", None)
938
 
939
+ # ── Back-fill missing canonical fields (schemaVersion: 1 β†’ 2) ─────────────
940
  out.setdefault("schemaVersion", SCHEMA_VERSION)
941
  out.setdefault("feedbackId", None)
942
  out.setdefault("action", "rate")
943
  out.setdefault("prevFeedbackId", None)
944
+ # editCount: None for retraction tombstones (not applicable), 0 for any
945
+ # pre-v2 "rate" record that predates this column.
946
+ out.setdefault("editCount", None if out.get("action") == "retract" else 0)
947
  out.setdefault("status", "active")
 
948
  out.setdefault("message", "")
949
  out.setdefault("query", "")
950
  out.setdefault("answer", "")
951
  out.setdefault("page", "")
952
 
953
+ # ── consentVersion: always resolved through _resolve_consent_version so
954
+ # historical "v1.0" values and new None values are consistent across the
955
+ # whole dataset while CONSENT_VERSION_ENABLED is False. ───────────────────
956
+ out["consentVersion"] = _resolve_consent_version(out.get("consentVersion"))
957
+
958
+ # ── Defensive re-coercion of identifier/count fields on legacy rows ───────
959
+ # Idempotent for already-canonical rows; guards against malformed legacy
960
+ # data (e.g. non-string IDs) reaching the DataFrame.
961
+ out["conversationId"] = _safe_id(out.get("conversationId"))
962
+ out["feedbackId"] = _safe_id(out.get("feedbackId"))
963
+ out["prevFeedbackId"] = _safe_id(out.get("prevFeedbackId"))
964
+ if out.get("action") != "retract":
965
+ out["editCount"] = _safe_int(out.get("editCount"), default=0)
966
+
967
  # ── Normalise model shape ─────────────────────────────────────────────────
968
  raw_model = out.get("model")
969
  if isinstance(raw_model, dict):