Spaces:
Running
Running
Upload 7 files
Browse files- DATASET_COLLECTION_GUIDANCE.md +46 -0
- _dataset_schema.py +872 -0
- 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
|
| 139 |
try:
|
| 140 |
-
from .
|
| 141 |
normalize_contribution_record,
|
| 142 |
normalize_feedback_record,
|
| 143 |
)
|
| 144 |
except Exception:
|
| 145 |
-
from
|
| 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
|
| 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
|
| 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,
|