Spaces:
Running
Running
Upload 6 files
Browse files- DATASET_COLLECTION_GUIDANCE.md +89 -3
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| `
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1537 |
client_ip = _client_ip(request)
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
if
|
| 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=
|
| 1549 |
-
detail="
|
| 1550 |
-
headers={"Retry-After": "3600"},
|
| 1551 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1552 |
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
}
|