celik-muhammed commited on
Commit
5747d32
Β·
verified Β·
1 Parent(s): eb97355

Upload 6 files

Browse files
Files changed (2) hide show
  1. DATASET_COLLECTION_GUIDANCE.md +89 -3
  2. app.py +91 -32
DATASET_COLLECTION_GUIDANCE.md CHANGED
@@ -74,6 +74,47 @@ Rationale:
74
  Priority: contribution > feedback
75
  ```
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  ---
78
 
79
  ## 4. Dataset Folder Structure
@@ -127,6 +168,9 @@ Notes
127
  -----
128
  * Priority rule: "contribution" beats "feedback" for the same _dedup_key.
129
  * Records without _dedup_key are retained as-is (legacy, pre-v1.0 records).
 
 
 
130
  * Script is idempotent: re-running produces the same output for the same
131
  dataset state.
132
  """
@@ -200,6 +244,14 @@ def deduplicate(records: list[dict]) -> list[dict]:
200
  ``_SOURCE_PRIORITY`` value is kept. Ties are broken by server-write
201
  timestamp (``_ts``), keeping the most recent. This is deterministic:
202
  given the same input, the output is always the same.
 
 
 
 
 
 
 
 
203
  """
204
  keyed: dict[str, dict] = {} # _dedup_key β†’ winning record
205
  no_key: list[dict] = [] # legacy records without _dedup_key
@@ -225,7 +277,24 @@ def deduplicate(records: list[dict]) -> list[dict]:
225
  if rec.get("_ts", 0) > existing.get("_ts", 0):
226
  keyed[dk] = rec
227
 
228
- return list(keyed.values()) + no_key
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
 
231
  def write_output(records: list[dict], output_path: Path) -> None:
@@ -292,10 +361,19 @@ def main(argv: list[str] | None = None) -> int:
292
 
293
  print(f"Reading records from {local_dir} …")
294
  all_records = load_all_records(local_dir)
 
295
  print(f" {len(all_records)} total records read")
 
 
 
 
 
296
 
297
  clean = deduplicate(all_records)
298
- duplicates_removed = len(all_records) - len(clean)
 
 
 
299
  print(f" {duplicates_removed} duplicate(s) removed (priority rule applied)")
300
  print(f" {len(clean)} unique records retained")
301
 
@@ -348,6 +426,11 @@ Contribution records additionally carry:
348
  | `_model` | `object \| null` | Model ID and provider |
349
  | `_consentVersion` | `string` | Consent version string (e.g. `"v1.0"`) |
350
 
 
 
 
 
 
351
  ---
352
 
353
  ## 7. Enabling Feedback Persistence
@@ -375,7 +458,10 @@ dual-source collection:
375
  | `app.py` | Added `FEEDBACK_PERSIST_ENABLED` constant |
376
  | `app.py /v1/contribute` | Every stored JSONL record now carries `_source="contribution"` and `_dedup_key` |
377
  | `app.py /v1/feedback` | Optional HF persistence via `FEEDBACK_PERSIST_ENABLED`; stored records carry `_source="feedback"` and `_dedup_key` |
 
 
378
  | `ai-assistant.js detail` | Added `conversationId: _sessionId` to feedback POST payload (critical β€” server needs this for correct `_dedup_key`) |
379
  | `ai-assistant.js _feedbackStore` | Added `conversationId` field for self-documenting store entries |
380
  | `ai-assistant.js tRecords` | Added `_source: 'contribution'` to each record for self-describing POST payload |
381
- | `DATASET_COLLECTION_GUIDANCE.md` | This document |
 
 
74
  Priority: contribution > feedback
75
  ```
76
 
77
+ ### Retraction tombstones
78
+
79
+ When a user edits a previously submitted quick-feedback rating, the browser
80
+ sends a **retraction tombstone** record before the new rating. Tombstones
81
+ are stored in the `feedback/` folder alongside normal rating records and
82
+ carry these fields:
83
+
84
+ | Field | Value |
85
+ |-------|-------|
86
+ | `action` | `"retract"` |
87
+ | `prevSessionId` | `sessionId` of the original rating record |
88
+ | `_dedup_key` | identical to the original record's `_dedup_key` |
89
+ | `_ts` | server-write timestamp β€” always later than the original |
90
+ | `ratingValue` | *absent* |
91
+
92
+ Tombstones participate in the LWW loop inside `deduplicate()` because their
93
+ `_ts` is later than the original record's, ensuring the original rating is
94
+ not emitted. They are then **unconditionally removed from the clean output**:
95
+ a tombstone is never a valid training example.
96
+
97
+ **Normal terminal state** (edit completed, all three records share the same
98
+ `_dedup_key`):
99
+
100
+ ```
101
+ _ts 100 ratingValue=+1 ← original rating, kept during LWW, superseded
102
+ _ts 200 action="retract" ← tombstone, LWW suppresses +1; filtered from output
103
+ _ts 201 ratingValue=-1 ← new rating, LWW winner, written to clean output βœ“
104
+ ```
105
+
106
+ **Degenerate case** (tombstone wins β€” follow-up rating never reached the
107
+ server, e.g. network failure after the retraction was sent):
108
+
109
+ ```
110
+ _ts 100 ratingValue=+1 ← original rating, superseded
111
+ _ts 200 action="retract" ← LWW winner, but FILTERED from clean output
112
+ ```
113
+
114
+ Net result: the original +1 was explicitly retracted, so **no record is
115
+ emitted** for this key. Training data is never contaminated by either the
116
+ abandoned +1 or the tombstone.
117
+
118
  ---
119
 
120
  ## 4. Dataset Folder Structure
 
168
  -----
169
  * Priority rule: "contribution" beats "feedback" for the same _dedup_key.
170
  * Records without _dedup_key are retained as-is (legacy, pre-v1.0 records).
171
+ * Retraction tombstones (action="retract") are always excluded from the
172
+ clean output even if they win the LWW race β€” they carry no ratingValue
173
+ and must never enter a training job.
174
  * Script is idempotent: re-running produces the same output for the same
175
  dataset state.
176
  """
 
244
  ``_SOURCE_PRIORITY`` value is kept. Ties are broken by server-write
245
  timestamp (``_ts``), keeping the most recent. This is deterministic:
246
  given the same input, the output is always the same.
247
+
248
+ Retraction tombstones (``action="retract"``) are always excluded from the
249
+ final output even when they are the last-written record for a given key.
250
+ A tombstone carries no ``ratingValue`` and must never reach a training
251
+ job. The LWW loop above still uses tombstones to suppress an earlier
252
+ rating (correct behaviour: the tombstone's ``_ts`` is later than the
253
+ original record's), but the post-loop filter ensures tombstones cannot
254
+ leak into ``clean_dataset.jsonl``.
255
  """
256
  keyed: dict[str, dict] = {} # _dedup_key β†’ winning record
257
  no_key: list[dict] = [] # legacy records without _dedup_key
 
277
  if rec.get("_ts", 0) > existing.get("_ts", 0):
278
  keyed[dk] = rec
279
 
280
+ # Post-loop: discard retraction tombstones from the winning set.
281
+ #
282
+ # Scenario A (normal edit): user clicks +1 (saved), edits (tombstone
283
+ # saved at _ts+100ms), then clicks -1 (saved at _ts+101ms).
284
+ # LWW selects the -1 record. No tombstone in output. βœ“
285
+ #
286
+ # Scenario B (orphaned tombstone): +1 saved, tombstone saved, but the
287
+ # follow-up -1 never reaches the server. LWW selects the tombstone.
288
+ # Without this filter, clean_dataset.jsonl would contain a record with
289
+ # action="retract" and ratingValue absent β€” corrupting any training job.
290
+ # The filter silently drops the tombstone. The net effect is correct:
291
+ # the +1 was explicitly retracted, so no rating is emitted. βœ“
292
+ #
293
+ # Tombstones are intentionally kept in the keyed dict *during* the loop
294
+ # because their later _ts must still suppress the earlier +1. Removal
295
+ # happens only at this output stage.
296
+ clean_keyed = [r for r in keyed.values() if r.get("action") != "retract"]
297
+ return clean_keyed + no_key
298
 
299
 
300
  def write_output(records: list[dict], output_path: Path) -> None:
 
361
 
362
  print(f"Reading records from {local_dir} …")
363
  all_records = load_all_records(local_dir)
364
+ retract_raw = sum(1 for r in all_records if r.get("action") == "retract")
365
  print(f" {len(all_records)} total records read")
366
+ if retract_raw:
367
+ print(
368
+ f" {retract_raw} retraction tombstone(s) in raw data "
369
+ f"(always excluded from clean output)"
370
+ )
371
 
372
  clean = deduplicate(all_records)
373
+ # retract_raw are excluded by deduplicate() before returning; subtract
374
+ # them so the "duplicates removed" count reflects actual cross-source
375
+ # duplicates rather than tombstones.
376
+ duplicates_removed = len(all_records) - retract_raw - len(clean)
377
  print(f" {duplicates_removed} duplicate(s) removed (priority rule applied)")
378
  print(f" {len(clean)} unique records retained")
379
 
 
426
  | `_model` | `object \| null` | Model ID and provider |
427
  | `_consentVersion` | `string` | Consent version string (e.g. `"v1.0"`) |
428
 
429
+ > **Note β€” retraction tombstones (`action="retract"`)** are always excluded
430
+ > from `clean_dataset.jsonl` by `deduplicate_dataset.py`. They are only
431
+ > present in the raw `feedback/` folder. The `action` field therefore never
432
+ > appears in the clean output described by this table.
433
+
434
  ---
435
 
436
  ## 7. Enabling Feedback Persistence
 
458
  | `app.py` | Added `FEEDBACK_PERSIST_ENABLED` constant |
459
  | `app.py /v1/contribute` | Every stored JSONL record now carries `_source="contribution"` and `_dedup_key` |
460
  | `app.py /v1/feedback` | Optional HF persistence via `FEEDBACK_PERSIST_ENABLED`; stored records carry `_source="feedback"` and `_dedup_key` |
461
+ | `app.py /v1/feedback` | Retraction tombstones (`action="retract"`) are detected, validated (`prevSessionId` required), rate-limit-exempt, logged as `feedback.retract`, and committed with `"Retract 1 feedback record"` message |
462
+ | `app.py rate limiter` | `_MAX_RL_ENTRIES` sweep now active in the feedback rate limiter to bound memory under unique-IP floods |
463
  | `ai-assistant.js detail` | Added `conversationId: _sessionId` to feedback POST payload (critical β€” server needs this for correct `_dedup_key`) |
464
  | `ai-assistant.js _feedbackStore` | Added `conversationId` field for self-documenting store entries |
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 |
app.py CHANGED
@@ -1533,41 +1533,86 @@ async def feedback(request: Request) -> JSONResponse:
1533
  except json.JSONDecodeError as exc:
1534
  raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") from exc
1535
 
1536
- # Per-IP rate limit: 30 per hour
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1537
  client_ip = _client_ip(request)
1538
- async with _feedback_rl_lock:
1539
- now = _time.time()
1540
- count, window_start = _feedback_rl.get(client_ip, (0, now))
1541
- if now - window_start > 3600:
1542
- count, window_start = 0, now
1543
- count += 1
1544
- _feedback_rl[client_ip] = (count, window_start)
1545
- if count > 30:
1546
- logger.warning(json.dumps({"event": "feedback.ratelimit", "ip": client_ip}))
1547
  raise HTTPException(
1548
- status_code=429,
1549
- detail="Rate limit exceeded. Maximum 30 feedback submissions per hour.",
1550
- headers={"Retry-After": "3600"},
1551
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1552
 
1553
- if not isinstance(payload, dict):
1554
- raise HTTPException(status_code=422, detail="Feedback body must be a JSON object.")
1555
-
1556
- logger.info(
1557
- json.dumps(
1558
- {
1559
- "event": "feedback.receive",
1560
- "ip": client_ip,
1561
- "ratingValue": payload.get("ratingValue"),
1562
- "model": payload.get("model"),
1563
- "conversationId": payload.get("conversationId"),
1564
- "answerIndex": payload.get("answerIndex"),
1565
- "page": payload.get("page"),
1566
- "ts": payload.get("ts"),
1567
- "persist": FEEDBACK_PERSIST_ENABLED,
1568
- }
1569
  )
1570
- )
1571
 
1572
  # ── Optional HF dataset persistence ─────────────────────────────────────
1573
  # Activated only when FEEDBACK_PERSIST_ENABLED=true AND the dataset repo
@@ -1575,6 +1620,11 @@ async def feedback(request: Request) -> JSONResponse:
1575
  # so that a dataset-write error never breaks the user's rating experience
1576
  # (the keepalive fire-and-forget model means the user won't see a retry UI
1577
  # anyway). Operators should monitor "feedback.persist_fail" log events.
 
 
 
 
 
1578
  if FEEDBACK_PERSIST_ENABLED and TRAINING_DATASET_REPO and HF_DATASET_TOKEN:
1579
  try:
1580
  from huggingface_hub import CommitOperationAdd, HfApi
@@ -1594,7 +1644,12 @@ async def feedback(request: Request) -> JSONResponse:
1594
  "_ts": int(_time.time() * 1000),
1595
  # Provenance β€” training pipelines must discard "feedback"
1596
  # records whenever a "contribution" record exists for the
1597
- # same _dedup_key. See DATASET_COLLECTION_GUIDANCE.md.
 
 
 
 
 
1598
  "_source": "feedback",
1599
  # "{conversationId}:{answerIndex}" β€” identical key format
1600
  # to contribution records; equality check is sufficient to
@@ -1608,6 +1663,9 @@ async def feedback(request: Request) -> JSONResponse:
1608
  # Fix 3 (feedback): same event-loop fix as /v1/contribute β€” offload
1609
  # the synchronous HF HTTP call to a worker thread so the uvicorn
1610
  # loop stays unblocked during the HuggingFace round-trip.
 
 
 
1611
  await asyncio.to_thread(
1612
  api.create_commit,
1613
  repo_id=TRAINING_DATASET_REPO,
@@ -1618,12 +1676,13 @@ async def feedback(request: Request) -> JSONResponse:
1618
  path_or_fileobj=record.encode(),
1619
  )
1620
  ],
1621
- commit_message="Add 1 feedback record",
1622
  )
1623
  logger.info(
1624
  json.dumps(
1625
  {
1626
  "event": "feedback.persist_ok",
 
1627
  "dedup_key": f"{conversation_id}:{answer_index}",
1628
  "ip": client_ip,
1629
  }
 
1533
  except json.JSONDecodeError as exc:
1534
  raise HTTPException(status_code=400, detail=f"Invalid JSON: {exc}") from exc
1535
 
1536
+ if not isinstance(payload, dict):
1537
+ raise HTTPException(status_code=422, detail="Feedback body must be a JSON object.")
1538
+
1539
+ # ── Distinguish retraction tombstones from regular ratings ───────────────
1540
+ # Retractions are system-generated housekeeping records that invalidate a
1541
+ # previous rating in the training dataset. They carry action="retract" and
1542
+ # prevSessionId (pointing to the original record) but NO ratingValue.
1543
+ # Key behavioural differences vs regular feedback:
1544
+ # 1. NOT counted against the per-IP rate limit β€” a user who edits 10
1545
+ # answers would otherwise exhaust their 30-slot hourly budget on cleanup
1546
+ # records alone (10 originals + 10 retractions + 10 new ratings = 30).
1547
+ # 2. Validated differently β€” prevSessionId is required; ratingValue is absent.
1548
+ # 3. Logged with event "feedback.retract" so operators can distinguish
1549
+ # retraction volume from new-rating volume in log dashboards.
1550
+ # 4. Committed with a distinct commit_message so the HF repo history is legible.
1551
+ is_retract: bool = payload.get("action") == "retract"
1552
+
1553
  client_ip = _client_ip(request)
1554
+
1555
+ if is_retract:
1556
+ # Validate required retraction fields before touching any rate-limit state.
1557
+ if not payload.get("prevSessionId"):
 
 
 
 
 
1558
  raise HTTPException(
1559
+ status_code=422,
1560
+ detail="Retraction records must include a non-empty prevSessionId.",
 
1561
  )
1562
+ logger.info(
1563
+ json.dumps(
1564
+ {
1565
+ "event": "feedback.retract",
1566
+ "ip": client_ip,
1567
+ "prevSessionId": payload.get("prevSessionId"),
1568
+ "conversationId": payload.get("conversationId"),
1569
+ "answerIndex": payload.get("answerIndex"),
1570
+ "ts": payload.get("ts"),
1571
+ "persist": FEEDBACK_PERSIST_ENABLED,
1572
+ }
1573
+ )
1574
+ )
1575
+ else:
1576
+ # ── Per-IP rate limit: 30 regular ratings per hour ───────────────────
1577
+ # Retractions are exempt (see above). The limit guards against
1578
+ # programmatic flooding; normal interactive use stays well under it.
1579
+ async with _feedback_rl_lock:
1580
+ now = _time.time()
1581
+ # Sweep expired entries to bound memory under a unique-IP flood.
1582
+ # _MAX_RL_ENTRIES is the safety ceiling declared at module level.
1583
+ if len(_feedback_rl) >= _MAX_RL_ENTRIES:
1584
+ cutoff = now - 3600
1585
+ expired = [k for k, (_, ws) in _feedback_rl.items() if ws < cutoff]
1586
+ for k in expired:
1587
+ del _feedback_rl[k]
1588
+ count, window_start = _feedback_rl.get(client_ip, (0, now))
1589
+ if now - window_start > 3600:
1590
+ count, window_start = 0, now
1591
+ count += 1
1592
+ _feedback_rl[client_ip] = (count, window_start)
1593
+ if count > 30:
1594
+ logger.warning(json.dumps({"event": "feedback.ratelimit", "ip": client_ip}))
1595
+ raise HTTPException(
1596
+ status_code=429,
1597
+ detail="Rate limit exceeded. Maximum 30 feedback submissions per hour.",
1598
+ headers={"Retry-After": "3600"},
1599
+ )
1600
 
1601
+ logger.info(
1602
+ json.dumps(
1603
+ {
1604
+ "event": "feedback.receive",
1605
+ "ip": client_ip,
1606
+ "ratingValue": payload.get("ratingValue"),
1607
+ "model": payload.get("model"),
1608
+ "conversationId": payload.get("conversationId"),
1609
+ "answerIndex": payload.get("answerIndex"),
1610
+ "page": payload.get("page"),
1611
+ "ts": payload.get("ts"),
1612
+ "persist": FEEDBACK_PERSIST_ENABLED,
1613
+ }
1614
+ )
 
 
1615
  )
 
1616
 
1617
  # ── Optional HF dataset persistence ─────────────────────────────────────
1618
  # Activated only when FEEDBACK_PERSIST_ENABLED=true AND the dataset repo
 
1620
  # so that a dataset-write error never breaks the user's rating experience
1621
  # (the keepalive fire-and-forget model means the user won't see a retry UI
1622
  # anyway). Operators should monitor "feedback.persist_fail" log events.
1623
+ #
1624
+ # Retraction records ARE persisted (when persistence is enabled) because
1625
+ # the training pipeline needs to see them to suppress the original rating.
1626
+ # A retraction that arrives without persistence is a no-op β€” the original
1627
+ # rating also wasn't persisted, so there is nothing to suppress.
1628
  if FEEDBACK_PERSIST_ENABLED and TRAINING_DATASET_REPO and HF_DATASET_TOKEN:
1629
  try:
1630
  from huggingface_hub import CommitOperationAdd, HfApi
 
1644
  "_ts": int(_time.time() * 1000),
1645
  # Provenance β€” training pipelines must discard "feedback"
1646
  # records whenever a "contribution" record exists for the
1647
+ # same _dedup_key. Retraction tombstones also use
1648
+ # _source="feedback" so the LWW dedup on _dedup_key works
1649
+ # correctly (retraction overrides original; new rating
1650
+ # overrides retraction). deduplicate_dataset.py filters
1651
+ # action="retract" winners from the clean output.
1652
+ # See DATASET_COLLECTION_GUIDANCE.md Β§3 and Β§8.
1653
  "_source": "feedback",
1654
  # "{conversationId}:{answerIndex}" β€” identical key format
1655
  # to contribution records; equality check is sufficient to
 
1663
  # Fix 3 (feedback): same event-loop fix as /v1/contribute β€” offload
1664
  # the synchronous HF HTTP call to a worker thread so the uvicorn
1665
  # loop stays unblocked during the HuggingFace round-trip.
1666
+ commit_msg = (
1667
+ "Retract 1 feedback record" if is_retract else "Add 1 feedback record"
1668
+ )
1669
  await asyncio.to_thread(
1670
  api.create_commit,
1671
  repo_id=TRAINING_DATASET_REPO,
 
1676
  path_or_fileobj=record.encode(),
1677
  )
1678
  ],
1679
+ commit_message=commit_msg,
1680
  )
1681
  logger.info(
1682
  json.dumps(
1683
  {
1684
  "event": "feedback.persist_ok",
1685
+ "retract": is_retract,
1686
  "dedup_key": f"{conversation_id}:{answer_index}",
1687
  "ip": client_ip,
1688
  }