celik-muhammed commited on
Commit
9f7fa2d
Β·
verified Β·
1 Parent(s): 5d48e9f

Upload 7 files

Browse files
Files changed (3) hide show
  1. DATASET_COLLECTION_GUIDANCE.md +46 -0
  2. _dataset_schema.py +872 -0
  3. app.py +5 -5
DATASET_COLLECTION_GUIDANCE.md CHANGED
@@ -465,3 +465,49 @@ dual-source collection:
465
  | `ai-assistant.js tRecords` | Added `_source: 'contribution'` to each record for self-describing POST payload |
466
  | `deduplicate_dataset.py` | Post-loop filter removes tombstone winners from clean output; `main()` reports tombstone count separately so duplicate counts are accurate |
467
  | `DATASET_COLLECTION_GUIDANCE.md` | This document; added Β§3 Retraction tombstones, Β§6 tombstone exclusion note, Β§8 this table |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  | `ai-assistant.js tRecords` | Added `_source: 'contribution'` to each record for self-describing POST payload |
466
  | `deduplicate_dataset.py` | Post-loop filter removes tombstone winners from clean output; `main()` reports tombstone count separately so duplicate counts are accurate |
467
  | `DATASET_COLLECTION_GUIDANCE.md` | This document; added Β§3 Retraction tombstones, Β§6 tombstone exclusion note, Β§8 this table |
468
+
469
+ ## 9. Viewing the dataset from the AI panel
470
+
471
+ The Extended Settings sheet (Endpoint Configuration β†’ **Dataset Endpoint**)
472
+ surfaces the dataset directly in the browser, with no secret ever leaving the
473
+ server.
474
+
475
+ How the panel resolves the dataset repo (two sources, first match wins):
476
+
477
+ 1. **Explicit** β€” `ai_assistant_panel_dataset_repo = "org/repo"` in `conf.py`.
478
+ Highest trust; no network call. Use for offline docs or to pin a specific
479
+ repo.
480
+ 2. **Auto-discovery** β€” when a training URL is configured, the panel issues
481
+ `GET {proxyBase}/` and reads `training.dataset_repo` from the JSON. The
482
+ proxy already publishes this field, so no `conf.py` change is required for
483
+ standard deployments.
484
+
485
+ From the repo id the panel builds three clickable links:
486
+
487
+ | Card | URL |
488
+ |------|-----|
489
+ | Dataset root | `https://huggingface.co/datasets/<repo>` |
490
+ | Feedback records | `https://huggingface.co/datasets/<repo>/tree/main/feedback` |
491
+ | Contributions | `https://huggingface.co/datasets/<repo>/tree/main/contributions` |
492
+
493
+ **Token posture (read-only).** The same `GET /` response also reports
494
+ `tokens.hf_token_type`, `tokens.hf_write_token_type`, and
495
+ `tokens.least_privilege_mode`. The panel shows these as a short
496
+ read/write/fine-grained summary so operators can confirm least-privilege
497
+ configuration **without** reading Space logs. Token *values* are never sent to
498
+ the browser. When neither source is reachable, the section degrades to a
499
+ "Not configured" hint and the Space repository secret
500
+ (`TRAINING_DATASET_REPO`) continues to drive persistence server-side β€” both
501
+ the panel and repo-secret approaches are fully supported.
502
+
503
+ ## 10. Summary of Changes (vNEXT)
504
+
505
+ | Component | Change |
506
+ |-----------|--------|
507
+ | `_static/ai-assistant.js` | Added **Dataset Endpoint** subsection (E) to `_buildEndpointSheet`: proxy-discovery + explicit-config dataset links and read-only HF token-posture row |
508
+ | `_static/ai-assistant.js` | Project Links sheet now renders HuggingFace **Space / Dataset / Active Endpoint** cards (config-driven or auto-derived) |
509
+ | `_static/ai-assistant.js` | New **self-contained URL-hash share** tier (`#ai-share-c1.<fmt>.<base64url>`): a real navigable, server-free, any-device/any-browser permanent link; routed in `_checkShareHash` and used by the permanent-link button (data: URI kept as fallback) |
510
+ | `_static/ai-assistant.css` | Added `.ai-assistant-panel-ep-ext-dataset-*` rules (light + dark) for the Dataset Endpoint section |
511
+ | `__init__.py` | Injects `panelDatasetRepo` + `panelHf*` keys into `window.AI_ASSISTANT_CONFIG` and registers the matching `ai_assistant_panel_*` config values |
512
+ | `_example_conf.py` | Documented examples for every new key |
513
+ | `app.py` | **No change** β€” `GET /` already exposes `training.*` and `tokens.*` |
_dataset_schema.py ADDED
@@ -0,0 +1,872 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
11
+
12
+ Background
13
+ ----------
14
+ Two independent server endpoints write to the same HuggingFace dataset repo:
15
+
16
+ * ``POST /v1/feedback`` β†’ ``feedback/TIMESTAMP.jsonl``
17
+ * ``POST /v1/contribute`` β†’ ``contributions/TIMESTAMP.jsonl``
18
+
19
+ Before this module, these two paths used *different* field names for the same
20
+ logical concept (e.g. ``conversationId`` vs ``_sessionId``, ``page`` vs
21
+ ``_page``, ``model`` vs ``_model``), had an inconsistent ``ratingLabel``
22
+ type (snake_case slug for panel feedback; Title Case string for quick πŸ‘/πŸ‘Ž
23
+ feedback), and copied the entire raw client payload into feedback records
24
+ (including the legacy ``rating`` alias and unfiltered extra fields).
25
+
26
+ This module fixes all of those issues by providing a single canonical schema
27
+ that **both** endpoints write. Every stored JSONL row is an output of
28
+ :func:`normalize_feedback_record` or :func:`normalize_contribution_record`.
29
+ Old records written before this fix can be read through :func:`normalize_record`
30
+ which back-fills the new fields from the legacy ones.
31
+
32
+ Canonical key order (identical in every row)
33
+ --------------------------------------------
34
+ ::
35
+
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
44
+ ts
45
+
46
+ Notes
47
+ -----
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
55
+ ``ratingLabel`` is now always a snake_case slug (canonical identifier).
56
+ ``ratingTitle`` carries the human-readable display string for dashboards.
57
+ Old quick-feedback records that stored ``ratingLabel = "Not helpful"`` are
58
+ normalised: the Title Case string is moved to ``ratingTitle`` and a slug
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
80
+
81
+ import json
82
+ import re
83
+ from collections import OrderedDict
84
+ from pathlib import Path
85
+ from typing import Any
86
+
87
+ # ─────────────────────────────────────────────────────────────────────────────
88
+ # Schema constants
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.
97
+ CANONICAL_COLUMNS: list[str] = [
98
+ # ── Schema metadata ───────────────────────────────────────────────────────
99
+ "schemaVersion",
100
+ # ── Provenance (server-side, mandatory) ──────────────────────────────────
101
+ "_source", # "feedback" | "contribution"
102
+ "_ts", # server receive time, ms since epoch (int)
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)
114
+ "ratingSlug", # str | None: snake_case canonical slug ("helpful", "mostly_positive")
115
+ "ratingTitle", # str | None: human display string ("Helpful", "Mostly yes")
116
+ "ratingMode", # str | None: "quick" | "panel"
117
+ "message", # str: free-text user comment (empty string when absent)
118
+ # ── Conversation content ──────────────────────────────────────────────────
119
+ "query", # str: user question
120
+ "answer", # str: model response
121
+ # ── Model ────────────────────────────────────────────────────────────────
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
+ ]
129
+
130
+ #: Required keys for the normalised model sub-object.
131
+ #: Both feedback (3-key shape) and contribution (8-key shape) are expanded to
132
+ #: this full set; keys absent in the source are filled with ``None``.
133
+ MODEL_KEYS: list[str] = [
134
+ "id", # canonical model identifier (e.g. "Qwen2.5-Coder-7B-Instruct-hf")
135
+ "provider", # inference provider (e.g. "huggingface", "anthropic", "custom")
136
+ "model", # HF model path or model string (e.g. "Qwen/Qwen2.5-Coder-7B-Instruct")
137
+ "label", # human display name (e.g. "Qwen2.5-Coder-7B-Instruct (Qwen/HuggingFace)")
138
+ "endpoint", # inference endpoint URL (None when not configured)
139
+ "info_url", # documentation/info link for this model
140
+ "description", # short description text
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).
147
+ # The numeric rating is carried in ``ratingValue`` (-5 to +5 mapping to index 0..10).
148
+ # fmt: off
149
+ _PANEL_SCALE: list[dict[str, Any]] = [
150
+ {"slug": "terrible", "title": "Terrible", "scale": -5},
151
+ {"slug": "poor", "title": "Poor", "scale": -4},
152
+ {"slug": "unsatisfied", "title": "Unsatisfied","scale": -3},
153
+ {"slug": "negative", "title": "No", "scale": -2},
154
+ {"slug": "slightly_negative", "title": "Not really", "scale": -1},
155
+ {"slug": "neutral", "title": "Neutral", "scale": 0},
156
+ {"slug": "slightly_positive", "title": "Somewhat", "scale": +1},
157
+ {"slug": "mostly_positive", "title": "Mostly yes", "scale": +2},
158
+ {"slug": "good", "title": "Good", "scale": +3},
159
+ {"slug": "very_good", "title": "Very good", "scale": +4},
160
+ {"slug": "excellent", "title": "Excellent!", "scale": +5},
161
+ ]
162
+ # fmt: on
163
+
164
+ # The quick πŸ‘/πŸ‘Ž options. ``sentiment`` is used as the canonical slug
165
+ # (after the JS-side fix; old records stored ``title`` in ``ratingLabel``).
166
+ _QUICK_OPTS: list[dict[str, Any]] = [
167
+ {"slug": "not_helpful", "title": "Not helpful", "value": -1, "sentiment": "negative"},
168
+ {"slug": "helpful", "title": "Helpful", "value": +1, "sentiment": "positive"},
169
+ ]
170
+
171
+ # Derived lookup tables.
172
+ _SLUG_TO_TITLE: dict[str, str] = {
173
+ **{e["slug"]: e["title"] for e in _PANEL_SCALE},
174
+ **{e["slug"]: e["title"] for e in _QUICK_OPTS},
175
+ # Sentiment strings also accepted as slugs (old records may use "positive"/"negative").
176
+ **{e["sentiment"]: e["title"] for e in _QUICK_OPTS},
177
+ }
178
+ _TITLE_TO_SLUG: dict[str, str] = {
179
+ **{e["title"]: e["slug"] for e in _PANEL_SCALE},
180
+ **{e["title"]: e["slug"] for e in _QUICK_OPTS},
181
+ }
182
+ _SLUG_TO_SCALE: dict[str, int] = {e["slug"]: e["scale"] for e in _PANEL_SCALE}
183
+ _SCALE_TO_SLUG: dict[int, str] = {e["scale"]: e["slug"] for e in _PANEL_SCALE}
184
+ _VALUE_TO_QUICK: dict[int, dict] = {e["value"]: e for e in _QUICK_OPTS}
185
+
186
+ #: All known Title Case rating strings (old quick records use these in ratingLabel).
187
+ _KNOWN_TITLES: frozenset[str] = frozenset(_TITLE_TO_SLUG)
188
+
189
+ #: Regex that matches a valid snake_case slug (all lowercase + underscores).
190
+ _SLUG_RE: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9_]*[a-z0-9]$|^[a-z]$")
191
+
192
+ #: Regex detecting the quick-feedback sessionId format generated by the JS widget:
193
+ #: ``<conversationUUID>-quick-<answerIndex>-<ms-epoch>``.
194
+ _QUICK_SESSION_RE: re.Pattern[str] = re.compile(r"-quick-\d+-\d+$")
195
+
196
+
197
+ # ─────────────────────────────────────────────────────────────────────────────
198
+ # Model normalization
199
+ # ─────────────────────────────────────────────────────────────────────────────
200
+
201
+
202
+ def normalize_model(raw: dict[str, Any] | None) -> dict[str, Any] | None:
203
+ """Return a normalised model object with all ``MODEL_KEYS`` present.
204
+
205
+ Parameters
206
+ ----------
207
+ raw : dict or None
208
+ Raw model dict from either a feedback record (3-key shape:
209
+ ``{id, provider, model}``) or a contribution record (8-key shape:
210
+ ``{id, provider, model, label, endpoint, info_url, description, default}``).
211
+ ``None`` is returned unchanged.
212
+
213
+ Returns
214
+ -------
215
+ dict or None
216
+ All eight canonical keys present; absent source keys are ``None``.
217
+
218
+ Notes
219
+ -----
220
+ Developer note
221
+ This ensures ``df["model"].apply(lambda m: m["label"])`` works uniformly
222
+ across rows from both sources without ``KeyError``.
223
+
224
+ Examples
225
+ --------
226
+ >>> normalize_model({"id": "foo", "provider": "hf", "model": "Org/foo"})
227
+ {'id': 'foo', 'provider': 'hf', 'model': 'Org/foo', 'label': None,
228
+ 'endpoint': None, 'info_url': None, 'description': None, 'default': None}
229
+ """
230
+ if raw is None:
231
+ return None
232
+ if not isinstance(raw, dict):
233
+ return None
234
+ return {k: raw.get(k) for k in MODEL_KEYS}
235
+
236
+
237
+ # ─────────────────────────────────────────────────────────────────────────────
238
+ # Rating normalization
239
+ # ─────────────────────────────────────────────────────────────────────────────
240
+
241
+
242
+ def normalize_rating(
243
+ rating_value: int | None,
244
+ rating_label: str | None,
245
+ *,
246
+ rating_mode: str | None = None,
247
+ rating_title: str | None = None,
248
+ feedback_id: str | None = None,
249
+ ) -> dict[str, Any]:
250
+ """Derive canonical (ratingSlug, ratingTitle, ratingMode) from raw inputs.
251
+
252
+ Parameters
253
+ ----------
254
+ rating_value : int or None
255
+ Numeric rating score. Quick feedback uses -1/+1; panel uses -5..+5.
256
+ rating_label : str or None
257
+ Raw ``ratingLabel`` from the client payload. This may be:
258
+
259
+ * A snake_case slug (``"mostly_positive"``): panel feedback and all
260
+ records written after the JS-side fix.
261
+ * A Title Case string (``"Not helpful"``): old quick-feedback records
262
+ written before the JS-side fix.
263
+ * A sentiment string (``"positive"``/``"negative"``): transitional.
264
+
265
+ rating_mode : str or None, optional
266
+ ``"quick"`` or ``"panel"`` when the JS widget sends the new
267
+ ``ratingMode`` field. Autodetected from ``feedback_id`` and
268
+ ``rating_label`` when absent.
269
+ rating_title : str or None, optional
270
+ Human display string when the JS widget sends the new ``ratingTitle``
271
+ field. Derived from ``ratingSlug`` when absent.
272
+ feedback_id : str or None, optional
273
+ The per-submission ``feedbackId`` / ``sessionId``; used to autodetect
274
+ quick-feedback records by the ``-quick-`` pattern in older JS versions.
275
+
276
+ Returns
277
+ -------
278
+ dict
279
+ Keys: ``ratingSlug``, ``ratingTitle``, ``ratingMode``.
280
+ All values are ``str`` or ``None``.
281
+
282
+ Notes
283
+ -----
284
+ Developer note β€” Detection order:
285
+
286
+ 1. If ``rating_mode`` is already provided: use it directly.
287
+ 2. If ``feedback_id`` matches ``_QUICK_SESSION_RE``: quick mode.
288
+ 3. If ``rating_label`` is a known Title Case string: quick mode (old record).
289
+ 4. If ``rating_label`` is snake_case slug: panel mode.
290
+ 5. If ``rating_value`` is -1 or +1 and ``rating_label`` is absent: quick mode.
291
+ 6. Otherwise: panel mode (safe default).
292
+
293
+ Examples
294
+ --------
295
+ >>> normalize_rating(1, "Helpful") # old quick record
296
+ {'ratingSlug': 'helpful', 'ratingTitle': 'Helpful', 'ratingMode': 'quick'}
297
+ >>> normalize_rating(2, "mostly_positive") # panel record
298
+ {'ratingSlug': 'mostly_positive', 'ratingTitle': 'Mostly yes', 'ratingMode': 'panel'}
299
+ >>> normalize_rating(1, "helpful", rating_mode="quick") # new quick record
300
+ {'ratingSlug': 'helpful', 'ratingTitle': 'Helpful', 'ratingMode': 'quick'}
301
+ """
302
+ label_str: str = (rating_label or "").strip()
303
+ detected_mode: str | None = rating_mode
304
+
305
+ # ── Step 1: Autodetect mode ───────────────────────────────────────────────
306
+ if not detected_mode:
307
+ if feedback_id and _QUICK_SESSION_RE.search(feedback_id):
308
+ detected_mode = "quick"
309
+ elif label_str in _KNOWN_TITLES:
310
+ detected_mode = "quick"
311
+ elif label_str and _SLUG_RE.match(label_str):
312
+ detected_mode = "panel"
313
+ elif rating_value in (-1, 1) and not label_str:
314
+ detected_mode = "quick"
315
+ else:
316
+ detected_mode = "panel"
317
+
318
+ # ── Step 2: Derive slug ───────────────────────────────────────────────────
319
+ slug: str | None
320
+ if detected_mode == "quick":
321
+ if label_str in _TITLE_TO_SLUG:
322
+ # Old record: ratingLabel held the Title Case string.
323
+ slug = _TITLE_TO_SLUG[label_str]
324
+ elif label_str in _SLUG_TO_TITLE:
325
+ # New record or sentiment string already slug-like.
326
+ slug = label_str
327
+ elif rating_value in _VALUE_TO_QUICK:
328
+ slug = _VALUE_TO_QUICK[rating_value]["slug"]
329
+ else:
330
+ slug = None
331
+ else:
332
+ # Panel mode: ratingLabel is already a slug (or empty for retracts).
333
+ slug = label_str if (label_str and _SLUG_RE.match(label_str)) else None
334
+ # If slug missing but scale value present, derive from _SCALE_TO_SLUG.
335
+ if slug is None and rating_value is not None:
336
+ slug = _SCALE_TO_SLUG.get(rating_value)
337
+
338
+ # ── Step 3: Derive title ──────────────────────────────────────────────────
339
+ title: str | None
340
+ if rating_title:
341
+ title = rating_title # Explicit (new JS sends ratingTitle)
342
+ elif slug:
343
+ title = _SLUG_TO_TITLE.get(slug)
344
+ else:
345
+ title = None
346
+
347
+ return {
348
+ "ratingSlug": slug,
349
+ "ratingTitle": title,
350
+ "ratingMode": detected_mode if (slug is not None) else None,
351
+ }
352
+
353
+
354
+ # ─────────────────────────────────────────────────────────────────────────────
355
+ # Canonical record construction
356
+ # ─────────────────────────────────────────────────────────────────────────────
357
+
358
+
359
+ def _ordered(fields: dict[str, Any]) -> dict[str, Any]:
360
+ """Return ``fields`` re-ordered to match ``CANONICAL_COLUMNS``.
361
+
362
+ Parameters
363
+ ----------
364
+ fields : dict
365
+ Record dict with all canonical keys present.
366
+
367
+ Returns
368
+ -------
369
+ dict
370
+ Keys in ``CANONICAL_COLUMNS`` order; extra keys appended alphabetically.
371
+ """
372
+ ordered: dict[str, Any] = {}
373
+ for col in CANONICAL_COLUMNS:
374
+ ordered[col] = fields.get(col)
375
+ # Preserve any unexpected extra keys after the canonical set (future fields).
376
+ for k in sorted(fields):
377
+ if k not in ordered:
378
+ ordered[k] = fields[k]
379
+ return ordered
380
+
381
+
382
+ def normalize_feedback_record(
383
+ payload: dict[str, Any],
384
+ *,
385
+ server_ts_ms: int,
386
+ ) -> dict[str, Any]:
387
+ """Build a canonical record from a raw ``POST /v1/feedback`` payload.
388
+
389
+ Parameters
390
+ ----------
391
+ payload : dict
392
+ Raw JSON body received by the feedback endpoint. Handles both normal
393
+ rating records and ``action="retract"`` tombstones.
394
+ server_ts_ms : int
395
+ Server receive timestamp in milliseconds since epoch (``int(time.time() * 1000)``).
396
+ Pass the same value for the entire request to avoid per-call clock drift.
397
+
398
+ Returns
399
+ -------
400
+ dict
401
+ Canonical record with all ``CANONICAL_COLUMNS`` keys in order.
402
+
403
+ Notes
404
+ -----
405
+ Developer note β€” Security
406
+ The previous implementation used ``{**payload, ...}`` which forwarded
407
+ arbitrary client-supplied fields directly into the dataset. This
408
+ implementation whitelists only the known fields from the JS schema,
409
+ discarding unexpected keys. The legacy ``rating`` alias is dropped
410
+ (its value was always identical to ``ratingLabel``).
411
+
412
+ Developer note β€” Retract records
413
+ When ``payload["action"] == "retract"`` the rating / content / model
414
+ fields are filled with ``None``; ``prevFeedbackId`` is set from
415
+ ``payload["prevSessionId"]``.
416
+
417
+ Developer note β€” ratingLabel normalization
418
+ Old quick-feedback records set ``ratingLabel = opt.title`` (Title Case:
419
+ ``"Not helpful"`` / ``"Helpful"``); the new JS sets
420
+ ``ratingLabel = opt.slug`` (snake_case: ``"not_helpful"`` / ``"helpful"``).
421
+ :func:`normalize_rating` handles both transparently.
422
+
423
+ Examples
424
+ --------
425
+ >>> record = normalize_feedback_record(payload, server_ts_ms=1_700_000_000_000)
426
+ >>> list(record.keys()) == CANONICAL_COLUMNS
427
+ True
428
+ """
429
+ is_retract: bool = payload.get("action") == "retract"
430
+
431
+ # ── Identity ──────────────────────────────────────────────────────────────
432
+ conversation_id: str = payload.get("conversationId") or ""
433
+ # Feedback sessionId is the per-submission idempotency key, renamed to
434
+ # feedbackId to distinguish it from the chat-session conversationId.
435
+ feedback_id: str | None = payload.get("sessionId") or None
436
+ answer_index: int | None = payload.get("answerIndex")
437
+
438
+ # ── Retract-specific fields ───────────────────────────────────────────────
439
+ # prevSessionId in the retract payload points to the sessionId (= feedbackId)
440
+ # of the original record being invalidated.
441
+ prev_feedback_id: str | None = (
442
+ payload.get("prevSessionId") or None if is_retract else None
443
+ )
444
+
445
+ # ── Rating (None for retracts) ────────────────────────────────────────────
446
+ if is_retract:
447
+ rating_fields: dict[str, Any] = {
448
+ "ratingSlug": None,
449
+ "ratingTitle": None,
450
+ "ratingMode": None,
451
+ }
452
+ else:
453
+ rating_fields = normalize_rating(
454
+ payload.get("ratingValue"),
455
+ payload.get("ratingLabel"),
456
+ rating_mode=payload.get("ratingMode"), # new JS field (None if old)
457
+ rating_title=payload.get("ratingTitle"), # new JS field (None if old)
458
+ feedback_id=feedback_id,
459
+ )
460
+
461
+ # ── Model (None for quick feedback and retracts) ──────────────────────────
462
+ raw_model: dict | None = payload.get("model")
463
+ if isinstance(raw_model, str):
464
+ # Guard: old or malformed payloads sometimes send model as a bare string.
465
+ raw_model = {"id": raw_model, "provider": None, "model": raw_model}
466
+
467
+ # ── Assemble canonical record ─────────────────────────────────────────────
468
+ return _ordered({
469
+ "schemaVersion": int(payload.get("schemaVersion") or SCHEMA_VERSION),
470
+ "_source": "feedback",
471
+ "_ts": server_ts_ms,
472
+ "_dedup_key": f"{conversation_id}:{answer_index}",
473
+ "conversationId": conversation_id or None,
474
+ "feedbackId": feedback_id,
475
+ "answerIndex": int(answer_index) if answer_index is not None else None,
476
+ "action": "retract" if is_retract else "rate",
477
+ "prevFeedbackId": prev_feedback_id,
478
+ "status": "active",
479
+ "ratingValue": None if is_retract else payload.get("ratingValue"),
480
+ "ratingSlug": rating_fields["ratingSlug"],
481
+ "ratingTitle": rating_fields["ratingTitle"],
482
+ "ratingMode": rating_fields["ratingMode"],
483
+ "message": "" if is_retract else (payload.get("message") or ""),
484
+ "query": "" if is_retract else (payload.get("query") or ""),
485
+ "answer": "" if is_retract else (payload.get("answer") or ""),
486
+ "model": None if is_retract else normalize_model(raw_model),
487
+ "page": "" if is_retract else (payload.get("page") or ""),
488
+ "consentVersion": None, # feedback is implicit consent; no version stored
489
+ "ts": payload.get("ts"),
490
+ })
491
+
492
+
493
+ def normalize_contribution_record(
494
+ rec: dict[str, Any],
495
+ *,
496
+ envelope: dict[str, Any],
497
+ server_ts_ms: int,
498
+ ) -> dict[str, Any]:
499
+ """Build a canonical record from one turn in a ``POST /v1/contribute`` batch.
500
+
501
+ Parameters
502
+ ----------
503
+ rec : dict
504
+ A single item from ``payload["records"]`` (one per-turn ``tRecord``).
505
+ envelope : dict
506
+ The outer contribution POST body (contains ``sessionId``, ``page``,
507
+ ``model``, ``consentVersion``, etc.).
508
+ server_ts_ms : int
509
+ Server receive timestamp in milliseconds since epoch. Compute once
510
+ per request and pass to all calls so every row in the batch has the
511
+ same ``_ts``.
512
+
513
+ Returns
514
+ -------
515
+ dict
516
+ Canonical record with all ``CANONICAL_COLUMNS`` keys in order.
517
+
518
+ Notes
519
+ -----
520
+ Developer note β€” Field renaming
521
+ The previous implementation stored ``_sessionId``, ``_page``,
522
+ ``_model``, ``_consentVersion`` (underscore-prefixed server-side
523
+ names). These are now stored without the prefix (``conversationId``,
524
+ ``page``, ``model``, ``consentVersion``) matching the feedback schema.
525
+ ``_source``, ``_ts``, and ``_dedup_key`` keep their underscore prefix
526
+ because they are universal provenance fields managed exclusively by
527
+ the server.
528
+
529
+ Developer note β€” ``_ts`` consistency
530
+ All rows in a single contribute batch share the same ``server_ts_ms``
531
+ value. The previous inline ``int(_time.time() * 1000)`` inside a
532
+ list comprehension produced slightly different ``_ts`` values per row.
533
+ Callers must compute ``server_ts_ms`` once before iterating.
534
+
535
+ Examples
536
+ --------
537
+ >>> ts = int(time.time() * 1000)
538
+ >>> rows = [normalize_contribution_record(r, envelope=payload, server_ts_ms=ts)
539
+ ... for r in payload["records"] if isinstance(r, dict)]
540
+ """
541
+ # conversation_id is the JS _sessionId (stable per-page-load chat session UUID).
542
+ # The envelope calls it "sessionId" (without underscore); we rename to conversationId.
543
+ conversation_id: str = envelope.get("sessionId") or ""
544
+ answer_index: int | None = rec.get("answerIndex")
545
+
546
+ rating_fields = normalize_rating(
547
+ rec.get("ratingValue"),
548
+ rec.get("ratingLabel"),
549
+ rating_mode=rec.get("ratingMode"), # new JS field (None if old)
550
+ rating_title=rec.get("ratingTitle"), # new JS field (None if old)
551
+ feedback_id=None, # no per-event session ID for contributions
552
+ )
553
+
554
+ return _ordered({
555
+ "schemaVersion": int(envelope.get("schemaVersion") or SCHEMA_VERSION),
556
+ "_source": "contribution",
557
+ "_ts": server_ts_ms,
558
+ "_dedup_key": f"{conversation_id}:{answer_index}",
559
+ "conversationId": conversation_id or None,
560
+ "feedbackId": None, # contributions are batch-level; no per-event ID
561
+ "answerIndex": int(answer_index) if answer_index is not None else None,
562
+ "action": "rate",
563
+ "prevFeedbackId": None,
564
+ "status": "active",
565
+ "ratingValue": rec.get("ratingValue"),
566
+ "ratingSlug": rating_fields["ratingSlug"],
567
+ "ratingTitle": rating_fields["ratingTitle"],
568
+ "ratingMode": rating_fields["ratingMode"],
569
+ "message": rec.get("message") or "",
570
+ "query": rec.get("query") or "",
571
+ "answer": rec.get("answer") or "",
572
+ "model": normalize_model(envelope.get("model")),
573
+ "page": envelope.get("page") or "",
574
+ "consentVersion": envelope.get("consentVersion"),
575
+ "ts": rec.get("ts"),
576
+ })
577
+
578
+
579
+ # ─────────────────────────────────────────────────────────────────────────────
580
+ # Back-compat normalisation for old records
581
+ # ─────────────────────────────────────────────────────────────────────────────
582
+
583
+
584
+ def normalize_record(raw: dict[str, Any]) -> dict[str, Any]:
585
+ """Normalise any stored JSONL record (old or new) to the canonical schema.
586
+
587
+ Handles records written before the schema fix by detecting and mapping
588
+ legacy field names (``_sessionId``, ``_page``, ``_model``, ``_consentVersion``,
589
+ ``rating``) to their canonical equivalents.
590
+
591
+ Parameters
592
+ ----------
593
+ raw : dict
594
+ A single record dict as loaded from a JSONL file.
595
+
596
+ Returns
597
+ -------
598
+ dict
599
+ Canonical record. Idempotent: already-canonical records pass through
600
+ unchanged.
601
+
602
+ Notes
603
+ -----
604
+ Developer note β€” Priority
605
+ For any field that has both an old and a new name present in the same
606
+ raw record, the new canonical name takes precedence.
607
+
608
+ Examples
609
+ --------
610
+ >>> old_contribution = {"_sessionId": "abc", "_page": "http://...", ...}
611
+ >>> new_contribution = normalize_record(old_contribution)
612
+ >>> "conversationId" in new_contribution
613
+ True
614
+ >>> "_sessionId" not in new_contribution
615
+ True
616
+ """
617
+ source: str = raw.get("_source", "")
618
+ out: dict[str, Any] = dict(raw)
619
+
620
+ # ── Map legacy contribution field names β†’ canonical ────────────────────���──
621
+ if "_sessionId" in out and "conversationId" not in out:
622
+ out["conversationId"] = out.pop("_sessionId")
623
+ elif "_sessionId" in out:
624
+ out.pop("_sessionId") # canonical name already present; drop alias
625
+
626
+ if "_page" in out and "page" not in out:
627
+ out["page"] = out.pop("_page")
628
+ elif "_page" in out:
629
+ out.pop("_page")
630
+
631
+ if "_model" in out and "model" not in out:
632
+ out["model"] = out.pop("_model")
633
+ elif "_model" in out:
634
+ out.pop("_model")
635
+
636
+ if "_consentVersion" in out and "consentVersion" not in out:
637
+ out["consentVersion"] = out.pop("_consentVersion")
638
+ elif "_consentVersion" in out:
639
+ out.pop("_consentVersion")
640
+
641
+ # ── Map legacy feedback field names β†’ canonical ───────────────────────────
642
+ # sessionId in feedback was the per-submission idempotency key (now feedbackId).
643
+ # Do NOT rename for contribution records (contributions have no sessionId field).
644
+ if source == "feedback":
645
+ if "sessionId" in out and "feedbackId" not in out:
646
+ out["feedbackId"] = out.pop("sessionId")
647
+ elif "sessionId" in out:
648
+ out.pop("sessionId")
649
+
650
+ # prevSessionId in retract records β†’ prevFeedbackId.
651
+ if "prevSessionId" in out and "prevFeedbackId" not in out:
652
+ out["prevFeedbackId"] = out.pop("prevSessionId")
653
+ elif "prevSessionId" in out:
654
+ out.pop("prevSessionId")
655
+
656
+ # ── Drop legacy aliases ───────────────────────────────────────────────────
657
+ # ``rating`` was always == ``ratingLabel``; it provides no additional info.
658
+ out.pop("rating", None)
659
+
660
+ # ── Back-fill missing canonical fields ────────────────────────────────────
661
+ out.setdefault("schemaVersion", SCHEMA_VERSION)
662
+ out.setdefault("feedbackId", None)
663
+ out.setdefault("action", "rate")
664
+ out.setdefault("prevFeedbackId", None)
665
+ out.setdefault("status", "active")
666
+ out.setdefault("consentVersion", None)
667
+ out.setdefault("message", "")
668
+ out.setdefault("query", "")
669
+ out.setdefault("answer", "")
670
+ out.setdefault("page", "")
671
+
672
+ # ── Normalise model shape ─────────────────────────────────────────────────
673
+ raw_model = out.get("model")
674
+ if isinstance(raw_model, dict):
675
+ out["model"] = normalize_model(raw_model)
676
+
677
+ # ── Normalise rating fields ───────────────────────────────────────────────
678
+ # For old records that don't yet have ratingSlug/ratingTitle/ratingMode.
679
+ if "ratingSlug" not in out:
680
+ rf = normalize_rating(
681
+ out.get("ratingValue"),
682
+ out.get("ratingLabel"),
683
+ rating_mode=out.get("ratingMode"),
684
+ rating_title=out.get("ratingTitle"),
685
+ feedback_id=out.get("feedbackId"),
686
+ )
687
+ out["ratingSlug"] = rf["ratingSlug"]
688
+ out["ratingTitle"] = rf["ratingTitle"]
689
+ out["ratingMode"] = rf["ratingMode"]
690
+
691
+ # Keep ratingLabel in sync with ratingSlug for backward compat readers.
692
+ if out.get("ratingSlug") and not out.get("ratingLabel"):
693
+ out["ratingLabel"] = out["ratingSlug"]
694
+
695
+ return _ordered(out)
696
+
697
+
698
+ # ─────────────────────────────────────────────────────────────────────────────
699
+ # I/O helpers
700
+ # ─────────────────────────────────────────────────────────────────────────────
701
+
702
+
703
+ def load_jsonl_file(path: str | Path) -> list[dict[str, Any]]:
704
+ """Load and normalise all records from a single JSONL file.
705
+
706
+ Parameters
707
+ ----------
708
+ path : str or Path
709
+ Path to a ``.jsonl`` file (one JSON object per line; blank lines and
710
+ comment lines starting with ``#`` are skipped).
711
+
712
+ Returns
713
+ -------
714
+ list of dict
715
+ Normalised records. Malformed lines are skipped with a warning printed
716
+ to stderr.
717
+
718
+ Notes
719
+ -----
720
+ User note
721
+ Both ``feedback/TIMESTAMP.jsonl`` and ``contributions/TIMESTAMP.jsonl``
722
+ files are valid inputs; the normalisation step handles the field-name
723
+ differences transparently.
724
+ """
725
+ import sys
726
+
727
+ records: list[dict[str, Any]] = []
728
+ path = Path(path)
729
+ with path.open(encoding="utf-8") as fh:
730
+ for line_no, line in enumerate(fh, 1):
731
+ line = line.strip()
732
+ if not line or line.startswith("#"):
733
+ continue
734
+ try:
735
+ obj = json.loads(line)
736
+ except json.JSONDecodeError as exc:
737
+ print(
738
+ f"WARNING: {path}:{line_no}: JSON decode error β€” {exc}",
739
+ file=sys.stderr,
740
+ )
741
+ continue
742
+ if not isinstance(obj, dict):
743
+ print(
744
+ f"WARNING: {path}:{line_no}: expected JSON object, got "
745
+ f"{type(obj).__name__} β€” skipped",
746
+ file=sys.stderr,
747
+ )
748
+ continue
749
+ records.append(normalize_record(obj))
750
+ return records
751
+
752
+
753
+ def load_dataset(
754
+ feedback_dir: str | Path | None = None,
755
+ contributions_dir: str | Path | None = None,
756
+ *,
757
+ sort_by: str = "_ts",
758
+ ascending: bool = True,
759
+ ) -> "Any": # -> pd.DataFrame
760
+ """Load and combine feedback and contribution records into one pandas DataFrame.
761
+
762
+ Parameters
763
+ ----------
764
+ feedback_dir : str, Path, or None
765
+ Directory containing ``feedback/*.jsonl`` files, or a single
766
+ ``feedback.jsonl`` file. Skipped when ``None``.
767
+ contributions_dir : str, Path, or None
768
+ Directory containing ``contributions/*.jsonl`` files, or a single
769
+ ``contributions.jsonl`` file. Skipped when ``None``.
770
+ sort_by : str, optional
771
+ Column to sort the combined DataFrame by. Default ``"_ts"`` (server
772
+ receive time, ascending).
773
+ ascending : bool, optional
774
+ Sort direction. Default ``True``.
775
+
776
+ Returns
777
+ -------
778
+ pandas.DataFrame
779
+ Combined, normalised DataFrame with columns in ``CANONICAL_COLUMNS``
780
+ order. ``model`` column contains dict values (or ``NaN`` for rows with
781
+ no model info). Flat helper columns ``model_id``, ``model_provider``,
782
+ and ``model_name`` are appended for easy querying.
783
+
784
+ Raises
785
+ ------
786
+ ImportError
787
+ When ``pandas`` is not installed.
788
+
789
+ Notes
790
+ -----
791
+ User note β€” one-liner::
792
+
793
+ df = load_dataset("feedback/", "contributions/")
794
+ df.groupby("_source")["ratingValue"].mean()
795
+
796
+ User note β€” filtering retractions::
797
+
798
+ active = df[df["action"] != "retract"].copy()
799
+
800
+ User note β€” dedup (prefer contribution over feedback)::
801
+
802
+ df_deduped = (
803
+ df.sort_values(["_dedup_key", "_source"], ascending=[True, True])
804
+ .drop_duplicates(subset=["_dedup_key"], keep="last")
805
+ )
806
+
807
+ Developer note β€” model column
808
+ The ``model`` column holds Python dicts (or ``None`` β†’ pandas ``NaN``).
809
+ For JSON-serialisable storage use
810
+ ``df["model"] = df["model"].apply(json.dumps)``.
811
+
812
+ Examples
813
+ --------
814
+ >>> df = load_dataset("feedback/", "contributions/")
815
+ >>> df.dtypes["ratingValue"]
816
+ dtype('object')
817
+ >>> df.dtypes["_ts"]
818
+ dtype('int64')
819
+ """
820
+ try:
821
+ import pandas as pd
822
+ except ImportError as exc:
823
+ raise ImportError(
824
+ "pandas is required for load_dataset(). "
825
+ "Install it with: pip install pandas"
826
+ ) from exc
827
+
828
+ all_records: list[dict[str, Any]] = []
829
+
830
+ def _collect(directory: str | Path) -> None:
831
+ p = Path(directory)
832
+ if p.is_file():
833
+ all_records.extend(load_jsonl_file(p))
834
+ elif p.is_dir():
835
+ for jsonl_file in sorted(p.glob("*.jsonl")):
836
+ all_records.extend(load_jsonl_file(jsonl_file))
837
+
838
+ if feedback_dir is not None:
839
+ _collect(feedback_dir)
840
+ if contributions_dir is not None:
841
+ _collect(contributions_dir)
842
+
843
+ if not all_records:
844
+ # Return empty DataFrame with correct columns and dtypes.
845
+ return pd.DataFrame(columns=CANONICAL_COLUMNS)
846
+
847
+ df = pd.DataFrame(all_records)
848
+
849
+ # ── Ensure all canonical columns are present (back-compat) ────────────────
850
+ for col in CANONICAL_COLUMNS:
851
+ if col not in df.columns:
852
+ df[col] = None
853
+
854
+ # ── Reorder columns to canonical order ────────────────────────────────────
855
+ extra_cols = [c for c in df.columns if c not in CANONICAL_COLUMNS]
856
+ df = df[CANONICAL_COLUMNS + extra_cols]
857
+
858
+ # ── Flat model helper columns for easy querying ───────────────────────────
859
+ def _model_field(m: Any, key: str) -> Any:
860
+ if isinstance(m, dict):
861
+ return m.get(key)
862
+ return None
863
+
864
+ df["model_id"] = df["model"].apply(_model_field, key="id")
865
+ df["model_provider"] = df["model"].apply(_model_field, key="provider")
866
+ df["model_name"] = df["model"].apply(_model_field, key="model")
867
+
868
+ # ── Sort ──────────────────────────────────────────────────────────────────
869
+ if sort_by in df.columns:
870
+ df = df.sort_values(sort_by, ascending=ascending, ignore_index=True)
871
+
872
+ return df
app.py CHANGED
@@ -135,14 +135,14 @@ from fastapi import Depends, FastAPI, HTTPException, Request
135
  from fastapi.middleware.cors import CORSMiddleware
136
  from fastapi.responses import JSONResponse, Response, StreamingResponse
137
 
138
- # _shared_logic.py and dataset_schema.py must live alongside this file.
139
  try:
140
- from .dataset_schema import ( # type: ignore[import]
141
  normalize_contribution_record,
142
  normalize_feedback_record,
143
  )
144
  except Exception:
145
- from dataset_schema import ( # type: ignore[import]
146
  normalize_contribution_record,
147
  normalize_feedback_record,
148
  )
@@ -1253,7 +1253,7 @@ async def contribute(request: Request) -> JSONResponse: # noqa: PLR0912
1253
  # _consentVersion β†’ consentVersion) to the canonical schema shared with the
1254
  # feedback endpoint. It also normalises ratingLabel (slug vs Title Case),
1255
  # expands the model object to the full 8-key shape, and enforces key order.
1256
- # See dataset_schema.py for the full canonical column list.
1257
  rows_jsonl = "\n".join(
1258
  json.dumps(
1259
  normalize_contribution_record(
@@ -1828,7 +1828,7 @@ async def feedback(request: Request) -> JSONResponse:
1828
  # prevSessionId β†’ prevFeedbackId, and enforces canonical key order.
1829
  # The resulting record is structurally identical to a contribution row
1830
  # so both sources concatenate directly into one pandas DataFrame.
1831
- # See dataset_schema.py for the full canonical column list.
1832
  record = json.dumps(
1833
  normalize_feedback_record(
1834
  payload,
 
135
  from fastapi.middleware.cors import CORSMiddleware
136
  from fastapi.responses import JSONResponse, Response, StreamingResponse
137
 
138
+ # _shared_logic.py and _dataset_schema.py must live alongside this file.
139
  try:
140
+ from ._dataset_schema import ( # type: ignore[import]
141
  normalize_contribution_record,
142
  normalize_feedback_record,
143
  )
144
  except Exception:
145
+ from _dataset_schema import ( # type: ignore[import]
146
  normalize_contribution_record,
147
  normalize_feedback_record,
148
  )
 
1253
  # _consentVersion β†’ consentVersion) to the canonical schema shared with the
1254
  # feedback endpoint. It also normalises ratingLabel (slug vs Title Case),
1255
  # expands the model object to the full 8-key shape, and enforces key order.
1256
+ # See _dataset_schema.py for the full canonical column list.
1257
  rows_jsonl = "\n".join(
1258
  json.dumps(
1259
  normalize_contribution_record(
 
1828
  # prevSessionId β†’ prevFeedbackId, and enforces canonical key order.
1829
  # The resulting record is structurally identical to a contribution row
1830
  # so both sources concatenate directly into one pandas DataFrame.
1831
+ # See _dataset_schema.py for the full canonical column list.
1832
  record = json.dumps(
1833
  normalize_feedback_record(
1834
  payload,