celik-muhammed commited on
Commit
4d1756b
Β·
verified Β·
1 Parent(s): 6063557

Upload 18 files

Browse files
DATASET_COLLECTION_GUIDANCE.md CHANGED
@@ -1,6 +1,6 @@
1
  # Dataset Collection and Multi-Store Operations Guide
2
 
3
- ## Schema v3 privacy lifecycle β€” authoritative
4
 
5
  > **Run 6 policy:** where older historical sections below describe feedback as
6
  > training input, cross-source feedback/contribution joins, unversioned consent,
@@ -9,6 +9,19 @@
9
  - Ordinary `/v1/feedback` is rating telemetry only. Query, answer, note, model,
10
  page and conversation identity are discarded server-side; durable feedback is
11
  opt-in and marked `trainingStatus="telemetry"`.
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  - `/v1/contribute` requires `consentFlag=true` and the current
13
  `consentVersion`. Accepted content enters the mutable contribution receipt
14
  ledger with `trainingStatus="quarantined"`; it is not written directly to
@@ -175,7 +188,7 @@ are readable; Secret values are write-only in the settings interface.
175
  | `TRAINING_DATASET_REPO` | **Variable** | Legacy HF mode only | Repo ID is normally non-sensitive |
176
  | `HF_DATASET_TOKEN` | **Secret** | Legacy HF mode | Actual HF credential |
177
  | `HF_DATASET_TOKEN_TYPE` | **Variable** | Legacy HF mode | Classification only |
178
- | `FEEDBACK_PERSIST_ENABLED` | **Variable** | Recommended | Boolean config |
179
 
180
  > **Attention β€” your existing `TRAINING_DATASET_REPO` Secret works, but it does
181
  > not normally need to be a Secret.**
@@ -770,8 +783,12 @@ Both files should contain identical bytes.
770
  ### Rating telemetry
771
 
772
  The browser does not send rating telemetry until the reader explicitly enables
773
- **Send rating telemetry**. With that preference enabled and
774
- `FEEDBACK_PERSIST_ENABLED=true`, a rating may produce:
 
 
 
 
775
 
776
  ```text
777
  feedback/YYYY/MM/DD/fb_<recordId>.jsonl
@@ -779,7 +796,10 @@ feedback/YYYY/MM/DD/fb_<recordId>.jsonl
779
 
780
  Inspect the row and verify it contains rating/event mechanics only: no query,
781
  answer, note, model, page URL, or conversation identifier, and
782
- `trainingStatus="telemetry"`. Storage failure must not break the rating UI.
 
 
 
783
 
784
  ### Contribution receipt lifecycle
785
 
@@ -790,7 +810,7 @@ delete/withdraw capability. The content is training-ineligible at this stage.
790
  Only an authorized operator review/promotion request may create durable
791
  `trainingStatus="eligible"` rows. Promotion uses an atomic lifecycle claim so a
792
  second concurrent reviewer cannot write the same receipt again. After promotion,
793
- raw Q&A content is cleared from the receipt ledger; lifecycle metadata, server-owned
794
  dedup keys, provider paths, and only the delete-capability hash remain.
795
 
796
  The same receipt capability remains valid after promotion. DELETE then means
@@ -904,35 +924,31 @@ empty segments, excessive depth, and unsafe names are rejected.
904
 
905
  ---
906
 
907
- ## 14. Why feedback and contributions must be deduplicated
908
 
909
- There are two intentional collection paths:
910
 
911
- | Path | Endpoint | Typical folder | Trigger |
912
  |---|---|---|---|
913
- | Immediate feedback | `POST /v1/feedback` | `feedback/` | user rates an answer |
914
- | Explicit contribution | `POST /v1/contribute` | `contributions/` | user consents and contributes conversation data |
915
 
916
- The same Q&A may therefore appear once as feedback and later again as a
917
- contribution.
 
918
 
919
- Training both copies would overweight that example.
920
-
921
- ### Canonical dedup key
922
-
923
- ```text
924
- _dedup_key = <conversationId>:<answerIndex>
925
- ```
926
-
927
- ### Priority
928
 
929
  ```text
930
- contribution > feedback
 
931
  ```
932
 
933
- For the same `_dedup_key`, the contribution wins.
934
-
935
- Within the same source type, the record with the newest server `_ts` wins.
 
936
 
937
  ---
938
 
@@ -1624,7 +1640,7 @@ conflict resolution could hide corruption or unauthorized modification.
1624
  - exact legacy duplicate records across mirrors suppressed;
1625
  - same canonical record ID + different bytes fails closed;
1626
  - guarded remote archive extraction;
1627
- - historical schema normalization to current schema v3 when `_utils/_dataset_schema.py` is available;
1628
  - default exclusion of feedback telemetry/quarantined/legacy-unreviewed records;
1629
  - training acceptance only for `trainingStatus="eligible"` contributions;
1630
  - tombstone/retraction handling where applicable;
 
1
  # Dataset Collection and Multi-Store Operations Guide
2
 
3
+ ## Schema v4 privacy and contribution lifecycle β€” authoritative
4
 
5
  > **Run 6 policy:** where older historical sections below describe feedback as
6
  > training input, cross-source feedback/contribution joins, unversioned consent,
 
9
  - Ordinary `/v1/feedback` is rating telemetry only. Query, answer, note, model,
10
  page and conversation identity are discarded server-side; durable feedback is
11
  opt-in and marked `trainingStatus="telemetry"`.
12
+ - Current schema v4 keeps feedback telemetry and explicit content contribution
13
+ structurally separate. Contribution has two record families: `recordType="qa"`
14
+ for selected/rated Q&A and `recordType="conversation"` for one ordered
15
+ `messages[]` record. Whole-conversation mode does not explode turns into
16
+ independent training rows.
17
+ - The browser's dedicated **Contribute to dataset** sheet is the only content-bearing
18
+ contribution surface. **This Q&A**, **Rated answers**, and **Whole conversation**
19
+ converge on the same exact-JSON inspection, privacy preflight, consent, quarantine,
20
+ receipt-delete, and post-promotion withdrawal lifecycle. Share/export owns no
21
+ contribution controls.
22
+ - Schema v4 requires contribution consent `2.0.0`. Legacy schema v2/v3 clients may
23
+ continue using historical consent `1.0.0` for compatibility; that legacy consent
24
+ is not accepted for schema v4's broader conversation-record contract.
25
  - `/v1/contribute` requires `consentFlag=true` and the current
26
  `consentVersion`. Accepted content enters the mutable contribution receipt
27
  ledger with `trainingStatus="quarantined"`; it is not written directly to
 
188
  | `TRAINING_DATASET_REPO` | **Variable** | Legacy HF mode only | Repo ID is normally non-sensitive |
189
  | `HF_DATASET_TOKEN` | **Secret** | Legacy HF mode | Actual HF credential |
190
  | `HF_DATASET_TOKEN_TYPE` | **Variable** | Legacy HF mode | Classification only |
191
+ | `FEEDBACK_PERSIST_ENABLED` | **Variable** | Recommended | Server persistence flag; never substitutes for reader telemetry consent |
192
 
193
  > **Attention β€” your existing `TRAINING_DATASET_REPO` Secret works, but it does
194
  > not normally need to be a Secret.**
 
783
  ### Rating telemetry
784
 
785
  The browser does not send rating telemetry until the reader explicitly enables
786
+ **Send rating telemetry**. The permission is versioned and stored as a structured
787
+ browser consent record; missing, malformed, stale-version, or historical boolean
788
+ preferences fail closed to **Off**. Every network telemetry request carries
789
+ `telemetryConsent=true`, `telemetryConsentVersion="1.0.0"`, and the grant timestamp;
790
+ the bundled proxy/Worker reject requests that omit the current consent contract.
791
+ With that user permission active and `FEEDBACK_PERSIST_ENABLED=true`, a rating may produce:
792
 
793
  ```text
794
  feedback/YYYY/MM/DD/fb_<recordId>.jsonl
 
796
 
797
  Inspect the row and verify it contains rating/event mechanics only: no query,
798
  answer, note, model, page URL, or conversation identifier, and
799
+ `trainingStatus="telemetry"`. Turning telemetry off must stop future rating and
800
+ retraction network requests. Storage failure must not break the local rating UI.
801
+ The public `ai-assistant-feedback` DOM event is content-free for the same reason:
802
+ page listeners receive bounded rating mechanics, not the Q&A/note/model/page tuple.
803
 
804
  ### Contribution receipt lifecycle
805
 
 
810
  Only an authorized operator review/promotion request may create durable
811
  `trainingStatus="eligible"` rows. Promotion uses an atomic lifecycle claim so a
812
  second concurrent reviewer cannot write the same receipt again. After promotion,
813
+ raw contributed content is cleared from the receipt ledger; lifecycle metadata, server-owned
814
  dedup keys, provider paths, and only the delete-capability hash remain.
815
 
816
  The same receipt capability remains valid after promotion. DELETE then means
 
924
 
925
  ---
926
 
927
+ ## 14. Contribution record identity and deduplication
928
 
929
+ There are two intentionally separate collection paths:
930
 
931
+ | Path | Endpoint | Typical folder | Content semantics |
932
  |---|---|---|---|
933
+ | Rating telemetry | `POST /v1/feedback` | `feedback/` | bounded rating/event mechanics only; never training-eligible |
934
+ | Explicit contribution | `POST /v1/contribute` | `contributions/` | user-reviewed Q&A or one ordered conversation record |
935
 
936
+ Feedback telemetry no longer carries query/answer content, so it is **not** joined
937
+ with contributions to form training examples. It remains `trainingStatus="telemetry"`
938
+ and is excluded from ordinary training output.
939
 
940
+ Contribution dedup keys are server-owned and receipt-scoped rather than stable
941
+ browser conversation identifiers:
 
 
 
 
 
 
 
942
 
943
  ```text
944
+ Q&A record: <receipt-id>:<answer-index>
945
+ Conversation record: <receipt-id>:conversation
946
  ```
947
 
948
+ The same key is reused by later eligible/withdrawal lifecycle records so
949
+ last-write-wins can suppress a withdrawn contribution without exposing a stable
950
+ client identity. Within a key, the newest server `_ts` wins. A whole conversation
951
+ remains one record throughout quarantine, promotion, and withdrawal.
952
 
953
  ---
954
 
 
1640
  - exact legacy duplicate records across mirrors suppressed;
1641
  - same canonical record ID + different bytes fails closed;
1642
  - guarded remote archive extraction;
1643
+ - historical schema normalization to current schema v4 when `_utils/_dataset_schema.py` is available;
1644
  - default exclusion of feedback telemetry/quarantined/legacy-unreviewed records;
1645
  - training acceptance only for `trainingStatus="eligible"` contributions;
1646
  - tombstone/retraction handling where applicable;
README.md CHANGED
@@ -146,31 +146,72 @@ caller-supplied forwarding headers.
146
 
147
  ### `POST /v1/contribute` β€” payload schema
148
 
 
 
 
 
149
  ```json
150
  {
151
- "schemaVersion": 1,
152
- "consentFlag": true,
153
- "consentVersion": "v1.0",
154
- "sessionId": "uuid-string",
155
- "page": "https://your-docs-site/index.html",
156
- "model": { "id": "...", "provider": "...", "model": "..." },
157
  "records": [
158
  {
159
- "answerIndex": 0,
160
- "query": "What is a confusion matrix?",
161
- "answer": "A confusion matrix is...",
162
- "ratingValue": 2,
163
- "ratingLabel": "πŸ‘",
164
- "message": "optional free-text from user",
165
- "ts": 1781002584724
 
 
 
 
 
 
166
  }
167
  ]
168
  }
169
  ```
170
 
171
- Each accepted record receives a stable content-derived ID, but raw accepted content is **not** written to the provider repositories immediately. It enters the mutable contribution receipt ledger first with `trainingStatus="quarantined"`. Only an independently authorized promotion writes an `eligible` row beneath `contributions/YYYY/MM/DD/<record-id>.jsonl` in the Primary/Mirrors. Only answers the user has explicitly rated are included β€” unrated answers are skipped by the browser widget before the request is sent.
172
-
173
- The receipt's delete capability remains meaningful after promotion. While pending it removes the content from the active review ledger. After promotion the same capability records a privacy-minimal `withdraw` tombstone keyed by the server-owned contribution dedup key and attempts best-effort removal from each provider's current branch/view. Dataset construction applies the withdrawal through last-write-wins and excludes both the withdrawn eligible row and the tombstone itself from training output. Versioned repository history, database pages/WAL remnants, backups, CDN/provider logs, and infrastructure snapshots are **not** claimed physically erased.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  ---
176
 
@@ -280,7 +321,7 @@ Effort / Thinking fields and opens a per-model in-memory fallback circuit.
280
  |---|---|---:|---|
281
  | `RECORD_STORAGE_TARGETS` | Variable | New multi-store mode | Provider-neutral JSON topology with exactly one Primary and optional Mirrors. Contains token environment-variable **names**, never token values. |
282
  | `TRAINING_DATASET_REPO` | Variable | Legacy HF mode only | Backward-compatible HF Dataset repo ID. When `RECORD_STORAGE_TARGETS` is present, the explicit target topology is authoritative; this value may remain for rollback/older discovery consumers. |
283
- | `FEEDBACK_PERSIST_ENABLED` | Variable | No | Opt-in persistence of privacy-minimal `/v1/feedback` telemetry. Default is `false`; feedback is never training-eligible. |
284
  | `CONTRIBUTION_REVIEW_TOKEN` | Secret | For promotion | Operator-only token required to promote quarantined contribution intake to durable `trainingStatus=eligible` storage. |
285
  | `CONTRIBUTION_LEDGER_BACKEND` | Variable | No | `memory` (default), `sqlite` (local transactional/restart-durable), or `redis` (shared transactional authority across replicas in one Redis consistency domain). Redis persistence durability is deployment-conditional and is not inferred by the proxy. |
286
  | `CONTRIBUTION_LEDGER_SQLITE_PATH` | Variable | When using SQLite | Local SQLite database path. Keep it on deployment-owned persistent storage if restart durability is required. |
@@ -625,7 +666,7 @@ BASE=https://scikit-plots-ai.hf.space
625
 
626
  # 1. Liveness probe
627
  curl $BASE/health
628
- # {"status":"ok","version":"6.5.2"}
629
 
630
  # Optional deterministic stub rig status
631
  curl -s $BASE/health | python3 -m json.tool
@@ -664,23 +705,23 @@ curl -N $BASE/v1/chat/completions \
664
  curl $BASE/v1/contribute \
665
  -H "Content-Type: application/json" \
666
  -d '{
667
- "schemaVersion": 1,
668
- "consentFlag": true,
669
- "consentVersion": "v1.0",
670
- "sessionId": "test-session-001",
671
- "page": "https://example.com",
672
- "model": null,
673
  "records": [{
674
- "answerIndex": 0,
675
- "query": "test query",
676
- "answer": "test answer",
677
- "ratingValue": 2,
678
- "ratingLabel": "πŸ‘",
679
- "message": "",
680
- "ts": 1781002584724
 
681
  }]
682
  }'
683
- # {"contributed": true, "rows": 1, "recordId": "...", "primary": "...", "mirrors": {...}}
684
  ```
685
 
686
  ---
 
146
 
147
  ### `POST /v1/contribute` β€” payload schema
148
 
149
+ The current browser sends **schema v4** with consent **2.0.0**. The endpoint
150
+ supports two explicit contribution record families. A whole conversation is one
151
+ ordered record:
152
+
153
  ```json
154
  {
155
+ "schemaVersion": 4,
156
+ "consentFlag": true,
157
+ "consentVersion": "2.0.0",
158
+ "page": "https://your-docs-site/index.html",
159
+ "model": null,
 
160
  "records": [
161
  {
162
+ "recordType": "conversation",
163
+ "messages": [
164
+ {"role": "user", "content": "What is a confusion matrix?", "ts": 1781002584000},
165
+ {
166
+ "role": "assistant",
167
+ "content": "A confusion matrix is...",
168
+ "ts": 1781002584724,
169
+ "model": {"id": "...", "provider": "...", "model": "..."},
170
+ "feedback": {"ratingValue": 2, "ratingLabel": "helpful", "note": "optional"}
171
+ }
172
+ ],
173
+ "message": "optional contribution-level reviewer note",
174
+ "ts": 1781002585000
175
  }
176
  ]
177
  }
178
  ```
179
 
180
+ The dedicated **Contribute to dataset** sheet also supports **This Q&A** and
181
+ **Rated answers** scopes. Those produce `recordType="qa"` records using the
182
+ historical `query` / `answer` shape. The current v4 envelope always requires
183
+ `consentVersion="2.0.0"`; legacy schema v2/v3 clients remain accepted with their
184
+ historical `1.0.0` consent for compatibility, but legacy consent is not accepted
185
+ for the broader v4 contract.
186
+
187
+ Ordinary `/v1/feedback` remains privacy-minimal rating telemetry and never turns
188
+ into content contribution automatically. The browser keeps ratings local unless
189
+ the reader explicitly enables **Send rating telemetry**. Current telemetry uses a
190
+ versioned permission (`telemetryConsent=true`, `telemetryConsentVersion="1.0.0"`)
191
+ and the proxy rejects feedback requests that do not carry that current consent
192
+ contract. Enabling telemetry does not contribute the question, answer, note,
193
+ page, model, or conversation content. Turning telemetry off stops future sends;
194
+ it does not claim erasure of telemetry already accepted by a remote provider.
195
+
196
+ Each accepted contribution record receives a server receipt-scoped dedup key,
197
+ but raw accepted content is **not** written to provider repositories immediately.
198
+ It enters the mutable contribution receipt ledger first with
199
+ `trainingStatus="quarantined"`. Only an independently authorized promotion writes
200
+ an `eligible` row beneath `contributions/YYYY/MM/DD/<record-id>.jsonl` in the
201
+ Primary/Mirrors. Whole-conversation mode contributes one ordered conversation
202
+ record; rated-answer mode contributes only explicitly rated Q&A records; the
203
+ single-Q&A shortcut contributes only the selected Q&A after the same inspect,
204
+ privacy-review, and consent flow.
205
+
206
+ The receipt's delete capability remains meaningful after promotion. While
207
+ pending it removes the content from the active review ledger. After promotion
208
+ the same capability records a privacy-minimal `withdraw` tombstone keyed by the
209
+ server-owned contribution dedup key and attempts best-effort removal from each
210
+ provider's current branch/view. Dataset construction applies the withdrawal
211
+ through last-write-wins and excludes both the withdrawn eligible row and the
212
+ tombstone itself from training output. Versioned repository history, database
213
+ pages/WAL remnants, backups, CDN/provider logs, and infrastructure snapshots are
214
+ **not** claimed physically erased.
215
 
216
  ---
217
 
 
321
  |---|---|---:|---|
322
  | `RECORD_STORAGE_TARGETS` | Variable | New multi-store mode | Provider-neutral JSON topology with exactly one Primary and optional Mirrors. Contains token environment-variable **names**, never token values. |
323
  | `TRAINING_DATASET_REPO` | Variable | Legacy HF mode only | Backward-compatible HF Dataset repo ID. When `RECORD_STORAGE_TARGETS` is present, the explicit target topology is authoritative; this value may remain for rollback/older discovery consumers. |
324
+ | `FEEDBACK_PERSIST_ENABLED` | Variable | No | Server-side persistence permission for privacy-minimal `/v1/feedback` telemetry. Default is `false`; it cannot override the browser user-consent gate, and feedback is never training-eligible. |
325
  | `CONTRIBUTION_REVIEW_TOKEN` | Secret | For promotion | Operator-only token required to promote quarantined contribution intake to durable `trainingStatus=eligible` storage. |
326
  | `CONTRIBUTION_LEDGER_BACKEND` | Variable | No | `memory` (default), `sqlite` (local transactional/restart-durable), or `redis` (shared transactional authority across replicas in one Redis consistency domain). Redis persistence durability is deployment-conditional and is not inferred by the proxy. |
327
  | `CONTRIBUTION_LEDGER_SQLITE_PATH` | Variable | When using SQLite | Local SQLite database path. Keep it on deployment-owned persistent storage if restart durability is required. |
 
666
 
667
  # 1. Liveness probe
668
  curl $BASE/health
669
+ # {"status":"ok","version":"6.6.1"}
670
 
671
  # Optional deterministic stub rig status
672
  curl -s $BASE/health | python3 -m json.tool
 
705
  curl $BASE/v1/contribute \
706
  -H "Content-Type: application/json" \
707
  -d '{
708
+ "schemaVersion": 4,
709
+ "consentFlag": true,
710
+ "consentVersion": "2.0.0",
711
+ "page": "https://example.com",
712
+ "model": {"id":"test-model","provider":"test"},
 
713
  "records": [{
714
+ "recordType": "qa",
715
+ "answerIndex": 0,
716
+ "query": "test query",
717
+ "answer": "test answer",
718
+ "ratingValue": 2,
719
+ "ratingLabel": "helpful",
720
+ "message": "",
721
+ "ts": 1781002584724
722
  }]
723
  }'
724
+ # {"contributed": true, "status": "quarantined", "rows": 1, "receiptId": "...", "deleteToken": "..."}
725
  ```
726
 
727
  ---
_utils/_dataset_schema.py CHANGED
@@ -1,4 +1,4 @@
1
- # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/dataset_schema.py
2
  #
3
  # flake8: noqa: D213
4
  #
@@ -7,16 +7,17 @@
7
 
8
  """Canonical schema and normalization for collection records.
9
 
10
- Schema v3 separates two data classes:
11
 
12
  * ``feedback`` is privacy-minimal rating telemetry. Content, model, page and
13
  conversation identity are discarded even when legacy/direct callers submit them;
14
  ``trainingStatus`` is always ``telemetry``.
15
- * ``contribution`` is explicit-content intake. It carries versioned consent, is
16
- initially ``quarantined``, labels model attribution ``client_reported``, and is
17
- training-eligible only after an authorised review promotes it.
 
18
 
19
- Historical v1/v2 rows remain readable through :func:`normalize_record`, but old
20
  contributions become ``legacy_unreviewed`` rather than silently entering training.
21
  Client IP addresses are never dataset fields. See ``DATASET_COLLECTION_GUIDANCE.md``
22
  for lifecycle and retention policy.
@@ -40,8 +41,8 @@ logger = logging.getLogger(__name__)
40
  #: Increment when a breaking field-name change is introduced; additive
41
  #: changes (new optional columns, wider population of existing columns) bump
42
  #: this too so consumers can branch on ``schemaVersion`` to know which fields
43
- #: to expect. See "Schema version history" above for what changed in v2.
44
- SCHEMA_VERSION: int = 3
45
 
46
  #: Ordered list of canonical column names. Every stored JSONL row and every
47
  #: row in the pandas DataFrame will have these columns in exactly this order.
@@ -56,6 +57,7 @@ CANONICAL_COLUMNS: list[str] = [
56
  "conversationId", # legacy field; v3 feedback/contribution normalization writes None
57
  "feedbackId", # feedback event id only; contributions write None
58
  # ── Record descriptor ─────────────────────────────────────────────────────
 
59
  "answerIndex", # 0-based position of answer in the conversation
60
  "action", # "rate" | "retract"
61
  "prevFeedbackId", # feedbackId of the record this one supersedes/invalidates.
@@ -76,7 +78,8 @@ CANONICAL_COLUMNS: list[str] = [
76
  "message", # contribution text only; feedback telemetry writes empty string
77
  # ── Conversation content ──────────────────────────────────────────────────
78
  "query", # contribution user question; feedback telemetry writes empty string
79
- "answer", # contribution model response; feedback telemetry writes empty string
 
80
  # ── Model ────────────────────────────────────────────────────────────────
81
  "model", # dict | None: normalised 8-key model object (see MODEL_KEYS)
82
  "modelEvidence", # None | "client_reported" | "legacy_unverified"
@@ -88,7 +91,7 @@ CANONICAL_COLUMNS: list[str] = [
88
  ]
89
 
90
  #: Required keys for the normalised model sub-object.
91
- #: Both feedback (3-key shape) and contribution (8-key shape) are expanded to
92
  #: this full set; keys absent in the source are filled with ``None``.
93
  MODEL_KEYS: list[str] = [
94
  "id", # canonical model identifier (e.g. "Qwen2.5-Coder-7B-Instruct-hf")
@@ -109,7 +112,10 @@ MODEL_KEYS: list[str] = [
109
  #: whenever the displayed contribution terms change materially and update the
110
  #: browser ``CONSENT_VERSION`` in the same run.
111
  CONSENT_VERSION_ENABLED: bool = True
112
- RESERVED_CONSENT_VERSION: str = "1.0.0"
 
 
 
113
 
114
 
115
  def _resolve_consent_version(raw: Any) -> str | None:
@@ -139,8 +145,8 @@ def _resolve_consent_version(raw: Any) -> str | None:
139
 
140
  Examples
141
  --------
142
- >>> _resolve_consent_version("1.0.0")
143
- '1.0.0'
144
  >>> _resolve_consent_version(None)
145
  """
146
  if not CONSENT_VERSION_ENABLED:
@@ -562,6 +568,7 @@ def normalize_feedback_record(
562
  "_dedup_key": dedup,
563
  "conversationId": None,
564
  "feedbackId": feedback_id,
 
565
  "answerIndex": answer_index,
566
  "action": "retract" if is_retract else "rate",
567
  "prevFeedbackId": prev_feedback_id,
@@ -575,6 +582,7 @@ def normalize_feedback_record(
575
  "message": "",
576
  "query": "",
577
  "answer": "",
 
578
  "model": None,
579
  "modelEvidence": None,
580
  "page": "",
@@ -582,6 +590,65 @@ def normalize_feedback_record(
582
  "ts": payload.get("ts"),
583
  })
584
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  def normalize_contribution_record(
586
  rec: dict[str, Any],
587
  *,
@@ -590,12 +657,46 @@ def normalize_contribution_record(
590
  training_status: str = "quarantined",
591
  submission_id: str | None = None,
592
  ) -> dict[str, Any]:
593
- """Normalize one explicitly consented contribution record.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
 
595
- Model/provider metadata is a client report, not verified provenance.
596
- ``training_status`` is server-owned and defaults to ``quarantined``; only an
597
- authorised review/promotion path may create ``eligible`` records.
598
- """
599
  answer_index = rec.get("answerIndex")
600
  try:
601
  answer_index = int(answer_index) if answer_index is not None else None
@@ -606,9 +707,6 @@ def normalize_contribution_record(
606
  rating_mode=rec.get("ratingMode"), rating_title=rec.get("ratingTitle"),
607
  feedback_id=None,
608
  )
609
- if training_status not in {"quarantined", "eligible", "legacy_unreviewed"}:
610
- training_status = "quarantined"
611
- dedup_base = _safe_id(submission_id) or "pending"
612
  return _ordered({
613
  "schemaVersion": SCHEMA_VERSION,
614
  "_source": "contribution",
@@ -616,6 +714,7 @@ def normalize_contribution_record(
616
  "_dedup_key": f"{dedup_base}:{answer_index}",
617
  "conversationId": None,
618
  "feedbackId": None,
 
619
  "answerIndex": answer_index,
620
  "action": "rate",
621
  "prevFeedbackId": None,
@@ -626,9 +725,10 @@ def normalize_contribution_record(
626
  "ratingSlug": rating_fields["ratingSlug"],
627
  "ratingTitle": rating_fields["ratingTitle"],
628
  "ratingMode": rating_fields["ratingMode"],
629
- "message": rec.get("message") or "",
630
- "query": rec.get("query") or "",
631
- "answer": rec.get("answer") or "",
 
632
  "model": normalize_model(envelope.get("model")),
633
  "modelEvidence": "client_reported" if envelope.get("model") else None,
634
  "page": envelope.get("page") or "",
@@ -665,6 +765,7 @@ def normalize_contribution_withdrawal_record(
665
  "_dedup_key": key,
666
  "conversationId": None,
667
  "feedbackId": None,
 
668
  "answerIndex": answer_index,
669
  "action": "withdraw",
670
  "prevFeedbackId": None,
@@ -770,6 +871,7 @@ def normalize_record(raw: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
770
  # ── Back-fill missing canonical fields (schemaVersion: 1 β†’ 2) ─────────────
771
  out["schemaVersion"] = SCHEMA_VERSION
772
  out.setdefault("feedbackId", None)
 
773
  out.setdefault("action", "rate")
774
  out.setdefault("prevFeedbackId", None)
775
  # editCount: None for retraction tombstones (not applicable), 0 for any
@@ -780,6 +882,7 @@ def normalize_record(raw: dict[str, Any]) -> dict[str, Any]: # noqa: PLR0912
780
  out.setdefault("message", "")
781
  out.setdefault("query", "")
782
  out.setdefault("answer", "")
 
783
  out.setdefault("page", "")
784
  out.setdefault("modelEvidence", "legacy_unverified" if out.get("model") else None)
785
 
 
1
+ # scikitplot/_externals/_sphinx_ext/_sphinx_ai_assistant/_hf_spaces_proxy/_utils/_dataset_schema.py
2
  #
3
  # flake8: noqa: D213
4
  #
 
7
 
8
  """Canonical schema and normalization for collection records.
9
 
10
+ Schema v4 separates telemetry from two explicit contribution record families:
11
 
12
  * ``feedback`` is privacy-minimal rating telemetry. Content, model, page and
13
  conversation identity are discarded even when legacy/direct callers submit them;
14
  ``trainingStatus`` is always ``telemetry``.
15
+ * ``contribution`` is explicit-content intake. Q&A records retain the historical
16
+ ``query``/``answer`` shape while conversation records carry one ordered ``messages``
17
+ array. Both carry versioned consent, enter ``quarantined`` state, and are
18
+ training-eligible only after an authorised review promotes them.
19
 
20
+ Historical v1/v2/v3 rows remain readable through :func:`normalize_record`, but old
21
  contributions become ``legacy_unreviewed`` rather than silently entering training.
22
  Client IP addresses are never dataset fields. See ``DATASET_COLLECTION_GUIDANCE.md``
23
  for lifecycle and retention policy.
 
41
  #: Increment when a breaking field-name change is introduced; additive
42
  #: changes (new optional columns, wider population of existing columns) bump
43
  #: this too so consumers can branch on ``schemaVersion`` to know which fields
44
+ #: to expect. See the module docstring and collection guidance for version semantics.
45
+ SCHEMA_VERSION: int = 4
46
 
47
  #: Ordered list of canonical column names. Every stored JSONL row and every
48
  #: row in the pandas DataFrame will have these columns in exactly this order.
 
57
  "conversationId", # legacy field; v3 feedback/contribution normalization writes None
58
  "feedbackId", # feedback event id only; contributions write None
59
  # ── Record descriptor ─────────────────────────────────────────────────────
60
+ "recordType", # "qa" | "conversation" (feedback writes None)
61
  "answerIndex", # 0-based position of answer in the conversation
62
  "action", # "rate" | "retract"
63
  "prevFeedbackId", # feedbackId of the record this one supersedes/invalidates.
 
78
  "message", # contribution text only; feedback telemetry writes empty string
79
  # ── Conversation content ──────────────────────────────────────────────────
80
  "query", # contribution user question; feedback telemetry writes empty string
81
+ "answer", # Q&A contribution model response; feedback telemetry/conversations write empty string
82
+ "messages", # conversation contribution ordered message list; otherwise None
83
  # ── Model ────────────────────────────────────────────────────────────────
84
  "model", # dict | None: normalised 8-key model object (see MODEL_KEYS)
85
  "modelEvidence", # None | "client_reported" | "legacy_unverified"
 
91
  ]
92
 
93
  #: Required keys for the normalised model sub-object.
94
+ #: Legacy/model-bearing contribution shapes are expanded to
95
  #: this full set; keys absent in the source are filled with ``None``.
96
  MODEL_KEYS: list[str] = [
97
  "id", # canonical model identifier (e.g. "Qwen2.5-Coder-7B-Instruct-hf")
 
112
  #: whenever the displayed contribution terms change materially and update the
113
  #: browser ``CONSENT_VERSION`` in the same run.
114
  CONSENT_VERSION_ENABLED: bool = True
115
+ RESERVED_CONSENT_VERSION: str = "2.0.0"
116
+ FEEDBACK_TELEMETRY_CONSENT_VERSION: str = "1.0.0"
117
+ FEEDBACK_TELEMETRY_SCHEMA_VERSION: int = 4
118
+ LEGACY_CONSENT_VERSIONS: frozenset[str] = frozenset({"1.0.0"})
119
 
120
 
121
  def _resolve_consent_version(raw: Any) -> str | None:
 
145
 
146
  Examples
147
  --------
148
+ >>> _resolve_consent_version("2.0.0")
149
+ '2.0.0'
150
  >>> _resolve_consent_version(None)
151
  """
152
  if not CONSENT_VERSION_ENABLED:
 
568
  "_dedup_key": dedup,
569
  "conversationId": None,
570
  "feedbackId": feedback_id,
571
+ "recordType": None,
572
  "answerIndex": answer_index,
573
  "action": "retract" if is_retract else "rate",
574
  "prevFeedbackId": prev_feedback_id,
 
582
  "message": "",
583
  "query": "",
584
  "answer": "",
585
+ "messages": None,
586
  "model": None,
587
  "modelEvidence": None,
588
  "page": "",
 
590
  "ts": payload.get("ts"),
591
  })
592
 
593
+ _MAX_CONVERSATION_MESSAGES: int = 100
594
+ _MAX_CONVERSATION_MESSAGE_CHARS: int = 100_000
595
+ _MAX_CONTRIBUTION_NOTE_CHARS: int = 2_000
596
+
597
+
598
+ def _bounded_text(value: Any, *, limit: int) -> str:
599
+ """Return a bounded string for explicit contribution content."""
600
+ if not isinstance(value, str):
601
+ return ""
602
+ return value[:limit]
603
+
604
+
605
+ def normalize_conversation_messages(value: Any) -> list[dict[str, Any]]:
606
+ """Normalize one explicit whole-conversation message array.
607
+
608
+ Only ``user`` and ``assistant`` roles are accepted. Error/tool/system rows are
609
+ deliberately excluded from this training/evaluation contribution family.
610
+ Per-assistant model and rating metadata remain client-reported evidence.
611
+ """
612
+ if not isinstance(value, list):
613
+ return []
614
+ out: list[dict[str, Any]] = []
615
+ for raw in value[:_MAX_CONVERSATION_MESSAGES]:
616
+ if not isinstance(raw, dict):
617
+ continue
618
+ role = raw.get("role")
619
+ if role not in {"user", "assistant"}:
620
+ continue
621
+ content = _bounded_text(raw.get("content"), limit=_MAX_CONVERSATION_MESSAGE_CHARS)
622
+ if not content:
623
+ continue
624
+ item: dict[str, Any] = {
625
+ "role": role,
626
+ "content": content,
627
+ "ts": raw.get("ts") if isinstance(raw.get("ts"), (int, float)) and not isinstance(raw.get("ts"), bool) else None,
628
+ }
629
+ if role == "assistant":
630
+ raw_model = raw.get("model")
631
+ item["model"] = normalize_model(raw_model) if isinstance(raw_model, dict) else None
632
+ raw_feedback = raw.get("feedback")
633
+ if isinstance(raw_feedback, dict):
634
+ rating = normalize_rating(
635
+ raw_feedback.get("ratingValue"), raw_feedback.get("ratingLabel"),
636
+ rating_mode=raw_feedback.get("ratingMode"),
637
+ rating_title=raw_feedback.get("ratingTitle"), feedback_id=None,
638
+ )
639
+ item["feedback"] = {
640
+ "ratingValue": raw_feedback.get("ratingValue"),
641
+ "ratingSlug": rating["ratingSlug"],
642
+ "ratingTitle": rating["ratingTitle"],
643
+ "ratingMode": rating["ratingMode"],
644
+ "note": _bounded_text(raw_feedback.get("note"), limit=_MAX_CONTRIBUTION_NOTE_CHARS),
645
+ }
646
+ else:
647
+ item["feedback"] = None
648
+ out.append(item)
649
+ return out
650
+
651
+
652
  def normalize_contribution_record(
653
  rec: dict[str, Any],
654
  *,
 
657
  training_status: str = "quarantined",
658
  submission_id: str | None = None,
659
  ) -> dict[str, Any]:
660
+ """Normalize one explicitly consented Q&A or conversation contribution."""
661
+ if training_status not in {"quarantined", "eligible", "legacy_unreviewed"}:
662
+ training_status = "quarantined"
663
+ dedup_base = _safe_id(submission_id) or "pending"
664
+ declared_type = rec.get("recordType")
665
+ record_type = "conversation" if declared_type == "conversation" else "qa"
666
+
667
+ if record_type == "conversation":
668
+ messages = normalize_conversation_messages(rec.get("messages"))
669
+ return _ordered({
670
+ "schemaVersion": SCHEMA_VERSION,
671
+ "_source": "contribution",
672
+ "_ts": server_ts_ms,
673
+ "_dedup_key": f"{dedup_base}:conversation",
674
+ "conversationId": None,
675
+ "feedbackId": None,
676
+ "recordType": "conversation",
677
+ "answerIndex": None,
678
+ "action": "rate",
679
+ "prevFeedbackId": None,
680
+ "editCount": 0,
681
+ "status": "active",
682
+ "trainingStatus": training_status,
683
+ "ratingValue": None,
684
+ "ratingSlug": None,
685
+ "ratingTitle": None,
686
+ "ratingMode": None,
687
+ "message": _bounded_text(rec.get("message"), limit=_MAX_CONTRIBUTION_NOTE_CHARS),
688
+ "query": "",
689
+ "answer": "",
690
+ "messages": messages,
691
+ "model": None,
692
+ "modelEvidence": "client_reported_per_message" if any(
693
+ isinstance(m.get("model"), dict) for m in messages if m.get("role") == "assistant"
694
+ ) else None,
695
+ "page": envelope.get("page") or "",
696
+ "consentVersion": _resolve_consent_version(envelope.get("consentVersion")),
697
+ "ts": rec.get("ts"),
698
+ })
699
 
 
 
 
 
700
  answer_index = rec.get("answerIndex")
701
  try:
702
  answer_index = int(answer_index) if answer_index is not None else None
 
707
  rating_mode=rec.get("ratingMode"), rating_title=rec.get("ratingTitle"),
708
  feedback_id=None,
709
  )
 
 
 
710
  return _ordered({
711
  "schemaVersion": SCHEMA_VERSION,
712
  "_source": "contribution",
 
714
  "_dedup_key": f"{dedup_base}:{answer_index}",
715
  "conversationId": None,
716
  "feedbackId": None,
717
+ "recordType": "qa",
718
  "answerIndex": answer_index,
719
  "action": "rate",
720
  "prevFeedbackId": None,
 
725
  "ratingSlug": rating_fields["ratingSlug"],
726
  "ratingTitle": rating_fields["ratingTitle"],
727
  "ratingMode": rating_fields["ratingMode"],
728
+ "message": _bounded_text(rec.get("message"), limit=_MAX_CONTRIBUTION_NOTE_CHARS),
729
+ "query": _bounded_text(rec.get("query"), limit=_MAX_CONVERSATION_MESSAGE_CHARS),
730
+ "answer": _bounded_text(rec.get("answer"), limit=_MAX_CONVERSATION_MESSAGE_CHARS),
731
+ "messages": None,
732
  "model": normalize_model(envelope.get("model")),
733
  "modelEvidence": "client_reported" if envelope.get("model") else None,
734
  "page": envelope.get("page") or "",
 
765
  "_dedup_key": key,
766
  "conversationId": None,
767
  "feedbackId": None,
768
+ "recordType": None,
769
  "answerIndex": answer_index,
770
  "action": "withdraw",
771
  "prevFeedbackId": None,
 
871
  # ── Back-fill missing canonical fields (schemaVersion: 1 β†’ 2) ─────────────
872
  out["schemaVersion"] = SCHEMA_VERSION
873
  out.setdefault("feedbackId", None)
874
+ out.setdefault("recordType", "qa" if source == "contribution" else None)
875
  out.setdefault("action", "rate")
876
  out.setdefault("prevFeedbackId", None)
877
  # editCount: None for retraction tombstones (not applicable), 0 for any
 
882
  out.setdefault("message", "")
883
  out.setdefault("query", "")
884
  out.setdefault("answer", "")
885
+ out.setdefault("messages", None)
886
  out.setdefault("page", "")
887
  out.setdefault("modelEvidence", "legacy_unverified" if out.get("model") else None)
888
 
_utils/_shared_logic.py CHANGED
@@ -220,7 +220,7 @@ __all__ = [ # noqa: RUF022
220
  # ─────────────────────────────────────────────────────────────────────────────
221
 
222
  #: Proxy release version β€” bump on every breaking change.
223
- PROXY_VERSION: str = "6.5.2"
224
 
225
  #: HuggingFace Inference Providers router base URL (no trailing slash).
226
  #: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is
 
220
  # ─────────────────────────────────────────────────────────────────────────────
221
 
222
  #: Proxy release version β€” bump on every breaking change.
223
+ PROXY_VERSION: str = "6.6.1"
224
 
225
  #: HuggingFace Inference Providers router base URL (no trailing slash).
226
  #: Only used for Path 3 (standard provider models) when ``BACKEND_URL`` is
app.py CHANGED
@@ -110,7 +110,7 @@
110
  # RATE_LIMIT_REQUIRE_SHARED When true, local rate limiting is rejected and
111
  # Redis initialization/runtime failure returns HTTP 503.
112
  # RATE_LIMIT_REDIS_TIMEOUT_SECONDS Redis operation timeout; clamped 0.25..10 s.
113
- # FEEDBACK_PERSIST_ENABLED Persist privacy-minimal rating telemetry only. Default false.
114
  # CONTRIBUTION_REVIEW_TOKEN Server/operator token required to promote quarantined
115
  # contributions into durable training-eligible storage.
116
  # CONTRIBUTION_QUARANTINE_TTL_SECONDS Pending contribution lifetime. Default 86400.
@@ -180,6 +180,9 @@ try:
180
  from ._utils._dataset_schema import ( # type: ignore[import]
181
  CONSENT_VERSION_ENABLED,
182
  RESERVED_CONSENT_VERSION,
 
 
 
183
  normalize_contribution_record,
184
  normalize_contribution_withdrawal_record,
185
  normalize_feedback_record,
@@ -188,6 +191,9 @@ except Exception: # noqa: BLE001
188
  from _utils._dataset_schema import ( # type: ignore[import]
189
  CONSENT_VERSION_ENABLED,
190
  RESERVED_CONSENT_VERSION,
 
 
 
191
  normalize_contribution_record,
192
  normalize_contribution_withdrawal_record,
193
  normalize_feedback_record,
@@ -2069,6 +2075,8 @@ async def root() -> JSONResponse:
2069
  "dataset_repo": None,
2070
  "contribute_ready": _contribution_pipeline_ready(),
2071
  "feedback_persist_enabled": FEEDBACK_PERSIST_ENABLED,
 
 
2072
  },
2073
  "tokens": {
2074
  "hf_token_type": "unknown",
@@ -2430,13 +2438,19 @@ async def contribute(request: Request) -> JSONResponse:
2430
  raise HTTPException(status_code=422, detail="Contribution body must be a JSON object.")
2431
  if payload.get("consentFlag") is not True:
2432
  raise HTTPException(status_code=422, detail="Explicit contribution consent is required.")
2433
- if payload.get("consentVersion") != RESERVED_CONSENT_VERSION:
 
 
 
 
 
 
 
 
2434
  raise HTTPException(
2435
  status_code=422,
2436
  detail="Consent text changed or is missing. Reload the page and review consent again.",
2437
  )
2438
- if payload.get("schemaVersion") not in {2, 3}:
2439
- raise HTTPException(status_code=422, detail="Unsupported contribution schemaVersion.")
2440
  records = payload.get("records")
2441
  if not isinstance(records, list) or not records:
2442
  raise HTTPException(status_code=422, detail="records must be a non-empty list.")
@@ -2462,6 +2476,10 @@ async def contribute(request: Request) -> JSONResponse:
2462
  )
2463
  for rec in records if isinstance(rec, dict)
2464
  ]
 
 
 
 
2465
  if not normalized:
2466
  raise HTTPException(status_code=422, detail="No valid contribution records were supplied.")
2467
  encoded = ("\n".join(json.dumps(r, ensure_ascii=False) for r in normalized)).encode("utf-8")
@@ -3135,9 +3153,10 @@ async def share_delete(share_id: str, request: Request) -> JSONResponse:
3135
  async def feedback(request: Request) -> JSONResponse:
3136
  """Accept privacy-minimal rating telemetry.
3137
 
3138
- Legacy/direct callers may submit query, answer, comment, model, page or
 
3139
  conversation identifiers, but the server normalizer discards them before
3140
- optional persistence. Persistence is server opt-in
3141
  (``FEEDBACK_PERSIST_ENABLED=false`` by default) and persisted rows are
3142
  ``trainingStatus=telemetry`` so the training builder excludes them.
3143
  """
@@ -3154,6 +3173,22 @@ async def feedback(request: Request) -> JSONResponse:
3154
  status_code=422, detail="Feedback body must be a JSON object."
3155
  )
3156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3157
  # ── Distinguish retraction tombstones from regular ratings ───────────────
3158
  # Retractions are system-generated housekeeping records that invalidate a
3159
  # previous rating in the training dataset. They carry action="retract" and
 
110
  # RATE_LIMIT_REQUIRE_SHARED When true, local rate limiting is rejected and
111
  # Redis initialization/runtime failure returns HTTP 503.
112
  # RATE_LIMIT_REDIS_TIMEOUT_SECONDS Redis operation timeout; clamped 0.25..10 s.
113
+ # FEEDBACK_PERSIST_ENABLED Persist privacy-minimal consent-gated rating telemetry only. Default false.
114
  # CONTRIBUTION_REVIEW_TOKEN Server/operator token required to promote quarantined
115
  # contributions into durable training-eligible storage.
116
  # CONTRIBUTION_QUARANTINE_TTL_SECONDS Pending contribution lifetime. Default 86400.
 
180
  from ._utils._dataset_schema import ( # type: ignore[import]
181
  CONSENT_VERSION_ENABLED,
182
  RESERVED_CONSENT_VERSION,
183
+ LEGACY_CONSENT_VERSIONS,
184
+ FEEDBACK_TELEMETRY_CONSENT_VERSION,
185
+ FEEDBACK_TELEMETRY_SCHEMA_VERSION,
186
  normalize_contribution_record,
187
  normalize_contribution_withdrawal_record,
188
  normalize_feedback_record,
 
191
  from _utils._dataset_schema import ( # type: ignore[import]
192
  CONSENT_VERSION_ENABLED,
193
  RESERVED_CONSENT_VERSION,
194
+ LEGACY_CONSENT_VERSIONS,
195
+ FEEDBACK_TELEMETRY_CONSENT_VERSION,
196
+ FEEDBACK_TELEMETRY_SCHEMA_VERSION,
197
  normalize_contribution_record,
198
  normalize_contribution_withdrawal_record,
199
  normalize_feedback_record,
 
2075
  "dataset_repo": None,
2076
  "contribute_ready": _contribution_pipeline_ready(),
2077
  "feedback_persist_enabled": FEEDBACK_PERSIST_ENABLED,
2078
+ "feedback_telemetry_schema_version": FEEDBACK_TELEMETRY_SCHEMA_VERSION,
2079
+ "feedback_telemetry_consent_version": FEEDBACK_TELEMETRY_CONSENT_VERSION,
2080
  },
2081
  "tokens": {
2082
  "hf_token_type": "unknown",
 
2438
  raise HTTPException(status_code=422, detail="Contribution body must be a JSON object.")
2439
  if payload.get("consentFlag") is not True:
2440
  raise HTTPException(status_code=422, detail="Explicit contribution consent is required.")
2441
+ schema_version = payload.get("schemaVersion")
2442
+ if schema_version not in {2, 3, 4}:
2443
+ raise HTTPException(status_code=422, detail="Unsupported contribution schemaVersion.")
2444
+ consent_version = payload.get("consentVersion")
2445
+ if schema_version == 4:
2446
+ consent_ok = consent_version == RESERVED_CONSENT_VERSION
2447
+ else:
2448
+ consent_ok = consent_version in (LEGACY_CONSENT_VERSIONS | {RESERVED_CONSENT_VERSION})
2449
+ if not consent_ok:
2450
  raise HTTPException(
2451
  status_code=422,
2452
  detail="Consent text changed or is missing. Reload the page and review consent again.",
2453
  )
 
 
2454
  records = payload.get("records")
2455
  if not isinstance(records, list) or not records:
2456
  raise HTTPException(status_code=422, detail="records must be a non-empty list.")
 
2476
  )
2477
  for rec in records if isinstance(rec, dict)
2478
  ]
2479
+ normalized = [
2480
+ row for row in normalized
2481
+ if row.get("recordType") != "conversation" or bool(row.get("messages"))
2482
+ ]
2483
  if not normalized:
2484
  raise HTTPException(status_code=422, detail="No valid contribution records were supplied.")
2485
  encoded = ("\n".join(json.dumps(r, ensure_ascii=False) for r in normalized)).encode("utf-8")
 
3153
  async def feedback(request: Request) -> JSONResponse:
3154
  """Accept privacy-minimal rating telemetry.
3155
 
3156
+ Every request must carry the current explicit telemetry-consent marker.
3157
+ Direct callers may still submit query, answer, comment, model, page or
3158
  conversation identifiers, but the server normalizer discards them before
3159
+ optional persistence. Persistence is server opt-in
3160
  (``FEEDBACK_PERSIST_ENABLED=false`` by default) and persisted rows are
3161
  ``trainingStatus=telemetry`` so the training builder excludes them.
3162
  """
 
3173
  status_code=422, detail="Feedback body must be a JSON object."
3174
  )
3175
 
3176
+ # Network feedback is user-consented telemetry, not an implicit side effect
3177
+ # of clicking a local rating button. Reject legacy/misbehaving clients that
3178
+ # do not carry the current explicit consent marker. This check occurs before
3179
+ # rate-limit/storage work so denied telemetry consumes minimal server state.
3180
+ if payload.get("telemetryConsent") is not True or payload.get("telemetryConsentVersion") != FEEDBACK_TELEMETRY_CONSENT_VERSION:
3181
+ logger.warning(json.dumps({"event": "feedback.consent_required"}))
3182
+ raise HTTPException(
3183
+ status_code=403,
3184
+ detail="Explicit feedback telemetry permission is required.",
3185
+ )
3186
+ if payload.get("schemaVersion") != FEEDBACK_TELEMETRY_SCHEMA_VERSION:
3187
+ raise HTTPException(status_code=422, detail="Unsupported feedback telemetry schema version.")
3188
+ consent_at = payload.get("telemetryConsentAt")
3189
+ if not isinstance(consent_at, (int, float)) or isinstance(consent_at, bool) or consent_at <= 0:
3190
+ raise HTTPException(status_code=422, detail="Feedback telemetry consent timestamp is required.")
3191
+
3192
  # ── Distinguish retraction tombstones from regular ratings ───────────────
3193
  # Retractions are system-generated housekeeping records that invalidate a
3194
  # previous rating in the training dataset. They carry action="retract" and