ihuarraquax commited on
Commit
14a628b
Β·
1 Parent(s): eb9857b

Phase 1: bulk-upload endpoints (bulk-crops, screen-types, anchors)

Browse files
Files changed (2) hide show
  1. README.md +6 -5
  2. main.py +386 -10
README.md CHANGED
@@ -35,10 +35,10 @@ See the source repo for the full API reference. Main surface:
35
  - `GET /health` β€” service status
36
  - `GET /knowledge` β€” merged icon knowledge base (phash β†’ name)
37
  - `GET /model/version` β€” latest trained model metadata
38
- - `POST /contribute` β€” single crop + label
39
- - `POST /contribute/bulk` β€” batch of crops (planned, Phase 1)
40
- - `POST /upload/screen-types` β€” screen-type screenshot (planned, Phase 1)
41
- - `POST /upload/anchors` β€” anchor grid (planned, Phase 1)
42
  - `POST /admin/merge` β€” admin-only knowledge merge
43
 
44
  ## Secrets
@@ -48,6 +48,7 @@ Configured in Space Settings β†’ Secrets:
48
  - `HF_TOKEN` β€” fine-grained write token, scoped to both
49
  `datasets/sets-sto/warp-knowledge` (current `/contribute`) and
50
  `datasets/sets-sto/sto-icon-dataset` (Phase 1 bulk endpoints)
51
- - `HF_REPO_ID` β€” e.g. `sets-sto/warp-knowledge`
 
52
  - `ADMIN_KEY` β€” guards `/admin/*` endpoints
53
  - `MAX_REQ_PER_IP` β€” per-IP daily rate cap
 
35
  - `GET /health` β€” service status
36
  - `GET /knowledge` β€” merged icon knowledge base (phash β†’ name)
37
  - `GET /model/version` β€” latest trained model metadata
38
+ - `POST /contribute` β€” single crop + label (legacy, β†’ `sets-sto/warp-knowledge`)
39
+ - `POST /contribute/bulk-crops` β€” batch of confirmed crops + annotations (β†’ `sets-sto/sto-icon-dataset`)
40
+ - `POST /upload/screen-types` β€” batch of screen-type screenshots (β†’ `sets-sto/sto-icon-dataset`)
41
+ - `POST /upload/anchors` β€” batch of anchor grids (β†’ `sets-sto/sto-icon-dataset`)
42
  - `POST /admin/merge` β€” admin-only knowledge merge
43
 
44
  ## Secrets
 
48
  - `HF_TOKEN` β€” fine-grained write token, scoped to both
49
  `datasets/sets-sto/warp-knowledge` (current `/contribute`) and
50
  `datasets/sets-sto/sto-icon-dataset` (Phase 1 bulk endpoints)
51
+ - `HF_REPO_ID` β€” e.g. `sets-sto/warp-knowledge` (target for `/contribute`, `/admin/merge`)
52
+ - `HF_ICONS_REPO_ID` β€” e.g. `sets-sto/sto-icon-dataset` (target for Phase 1 bulk endpoints; defaults to that value if unset)
53
  - `ADMIN_KEY` β€” guards `/admin/*` endpoints
54
  - `MAX_REQ_PER_IP` β€” per-IP daily rate cap
main.py CHANGED
@@ -64,10 +64,30 @@ def _load_env():
64
  _load_env()
65
 
66
  # ── Config from environment ────────────────────────────────────────────────────
67
- HF_TOKEN = os.environ.get('HF_TOKEN', '')
68
- HF_REPO_ID = os.environ.get('HF_REPO_ID', 'sets-sto/warp-knowledge')
69
- ADMIN_KEY = os.environ.get('ADMIN_KEY', '')
70
- MAX_REQ_PER_IP = int(os.environ.get('MAX_REQ_PER_IP', '500'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  # GitHub Config for automated training triggers
73
  GH_TOKEN = os.environ.get('GH_TOKEN', '')
@@ -123,6 +143,52 @@ class ContributeRequest(BaseModel):
123
  return re.sub(r'[^a-zA-Z0-9\-_]', '', v)[:64]
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  # ── Endpoints ──────────────────────────────────────────────────────────────────
127
 
128
  @app.get('/health')
@@ -222,6 +288,225 @@ async def contribute(req: ContributeRequest, request: Request):
222
  return {'ok': True, 'contribution_id': contrib_id}
223
 
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  @app.post('/webhooks/hf-dataset')
226
  async def hf_dataset_webhook(request: Request):
227
  """
@@ -319,6 +604,62 @@ async def admin_merge(
319
 
320
  # ── Validation helpers ─────────────────────────────────────────────────────────
321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  def is_valid_crop(png_bytes: bytes) -> bool:
323
  """Checks if crop is valid (not garbage/too uniform)."""
324
  try:
@@ -334,10 +675,20 @@ def is_valid_crop(png_bytes: bytes) -> bool:
334
 
335
  # ── HF Dataset helpers ────────────────────���────────────────────────────────────
336
 
337
- def _hf_upload_files(files: dict[str, bytes], message: str = 'WARP auto-upload') -> bool:
338
- """Upload multiple files to HF Dataset repo atomically."""
339
- if not HF_TOKEN or not HF_REPO_ID:
340
- log.error('HF_TOKEN or HF_REPO_ID not set')
 
 
 
 
 
 
 
 
 
 
341
  return False
342
  try:
343
  from huggingface_hub import HfApi, CommitOperationAdd
@@ -347,17 +698,42 @@ def _hf_upload_files(files: dict[str, bytes], message: str = 'WARP auto-upload')
347
  for path, content in files.items()
348
  ]
349
  api.create_commit(
350
- repo_id=HF_REPO_ID,
351
  repo_type='dataset',
352
  operations=operations,
353
  commit_message=message,
354
  )
355
  return True
356
  except Exception as e:
357
- log.error(f'HF atomic upload failed: {e}')
358
  return False
359
 
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  def _load_model_version_from_hf() -> dict:
362
  """Download models/model_version.json from HF."""
363
  if not HF_REPO_ID:
 
64
  _load_env()
65
 
66
  # ── Config from environment ────────────────────────────────────────────────────
67
+ HF_TOKEN = os.environ.get('HF_TOKEN', '')
68
+ HF_REPO_ID = os.environ.get('HF_REPO_ID', 'sets-sto/warp-knowledge')
69
+ # Icon-dataset target for bulk-crops, screen-types and anchor-grid uploads
70
+ # (Phase 1 of backend-proxy migration). Separate from HF_REPO_ID because
71
+ # the icon training data lives in its own dataset.
72
+ HF_ICONS_REPO_ID = os.environ.get('HF_ICONS_REPO_ID', 'sets-sto/sto-icon-dataset')
73
+ ADMIN_KEY = os.environ.get('ADMIN_KEY', '')
74
+ MAX_REQ_PER_IP = int(os.environ.get('MAX_REQ_PER_IP', '500'))
75
+
76
+ # Bulk-endpoint payload limits β€” guards against accidental floods and HF
77
+ # commit-size limits. Per-item caps mirror client-side validation in
78
+ # warp/trainer/sync.py.
79
+ MAX_BULK_CROPS = 50
80
+ MAX_BULK_SCREEN_TYPES = 20
81
+ MAX_BULK_ANCHOR_GRIDS = 20
82
+ MAX_CROP_PNG_BYTES = 150_000 # icon crop
83
+ MAX_SCREEN_PNG_BYTES = 2_500_000 # full screenshot
84
+ MIN_CROP_PX = 16
85
+ MIN_TEXT_CROP_H = 10
86
+ MIN_TEXT_CROP_W = 50
87
+ MAX_NAME_LEN = 120
88
+ _TEXT_CROP_PREFIXES = ('ship_type_', 'ship_tier_')
89
+ _INSTALL_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{8,64}$')
90
+ _SCREEN_TYPE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,40}$')
91
 
92
  # GitHub Config for automated training triggers
93
  GH_TOKEN = os.environ.get('GH_TOKEN', '')
 
143
  return re.sub(r'[^a-zA-Z0-9\-_]', '', v)[:64]
144
 
145
 
146
+ # ── Phase 1: bulk-upload request models ───────────────────────────────────────
147
+ #
148
+ # These mirror the payloads previously built by warp/trainer/sync.py on the
149
+ # client side. The client used to call HfApi.create_commit directly with a
150
+ # write-scoped HF token; in Phase 2 it will POST these payloads to the
151
+ # backend instead, so the token never leaves the server.
152
+
153
+ class _BulkCropItem(BaseModel):
154
+ slot: str = Field(..., min_length=1, max_length=80)
155
+ name: str = Field(..., min_length=1, max_length=MAX_NAME_LEN)
156
+ crop_png_b64: str = Field(..., min_length=100, max_length=200_000)
157
+ ml_name: str = Field('', max_length=MAX_NAME_LEN)
158
+
159
+ @field_validator('name', 'ml_name', 'slot')
160
+ @classmethod
161
+ def _strip_ctrl(cls, v: str) -> str:
162
+ return re.sub(r'[\x00-\x1f\x7f]', '', v).strip()
163
+
164
+
165
+ class BulkCropsRequest(BaseModel):
166
+ install_id: str = Field(..., min_length=8, max_length=64)
167
+ items: list[_BulkCropItem] = Field(..., min_length=1, max_length=MAX_BULK_CROPS)
168
+
169
+
170
+ class _ScreenTypeItem(BaseModel):
171
+ png_b64: str = Field(..., min_length=100, max_length=4_000_000)
172
+
173
+
174
+ class ScreenTypesRequest(BaseModel):
175
+ install_id: str = Field(..., min_length=8, max_length=64)
176
+ screen_type: str = Field(..., min_length=1, max_length=40)
177
+ items: list[_ScreenTypeItem] = Field(..., min_length=1, max_length=MAX_BULK_SCREEN_TYPES)
178
+
179
+
180
+ class _AnchorGrid(BaseModel):
181
+ build_type: str = Field(..., min_length=1, max_length=40)
182
+ aspect: str | None = Field(None, max_length=16)
183
+ resolution: str = Field('', max_length=16)
184
+ slots: dict[str, list[float]] = Field(..., min_length=3)
185
+
186
+
187
+ class AnchorsRequest(BaseModel):
188
+ install_id: str = Field(..., min_length=8, max_length=64)
189
+ grids: list[_AnchorGrid] = Field(..., min_length=1, max_length=MAX_BULK_ANCHOR_GRIDS)
190
+
191
+
192
  # ── Endpoints ──────────────────────────────────────────────────────────────────
193
 
194
  @app.get('/health')
 
288
  return {'ok': True, 'contribution_id': contrib_id}
289
 
290
 
291
+ # ── Phase 1: bulk-upload endpoints ────────────────────────────────────────────
292
+ #
293
+ # These replace the direct HF writes that warp/trainer/sync.py used to
294
+ # perform with a client-side write token. Each endpoint accepts a batch,
295
+ # validates per-item, and produces a single HF commit so we stay well
296
+ # inside HF API rate limits.
297
+
298
+ @app.post('/contribute/bulk-crops')
299
+ async def contribute_bulk_crops(req: BulkCropsRequest, request: Request):
300
+ """Accept a batch of confirmed crops + annotations.
301
+
302
+ Mirrors warp/trainer/sync.py:_upload(): writes PNGs to
303
+ staging/<install_id>/crops/<sha>.png and rewrites
304
+ staging/<install_id>/annotations.jsonl with last-wins dedup per sha.
305
+ All writes happen in a single HF commit.
306
+ """
307
+ client_ip = _get_client_ip(request)
308
+ if not await _check_and_increment_rate_limit(client_ip):
309
+ raise HTTPException(429, 'Rate limit exceeded. Try again tomorrow.')
310
+
311
+ install_id = req.install_id.strip()
312
+ if not _INSTALL_ID_RE.match(install_id):
313
+ raise HTTPException(400, 'Invalid install_id format')
314
+
315
+ today = date.today().isoformat()
316
+ staging_dir = f'staging/{install_id}'
317
+ staging_crop = f'{staging_dir}/crops'
318
+ staging_anno = f'{staging_dir}/annotations.jsonl'
319
+
320
+ files_to_upload: dict[str, bytes] = {}
321
+ new_entries: list[dict] = []
322
+ accepted = 0
323
+ rejected = 0
324
+ reasons: list[str] = []
325
+
326
+ for item in req.items:
327
+ name = item.name.strip()
328
+ slot = item.slot.strip()
329
+ if not name or not slot:
330
+ rejected += 1
331
+ reasons.append('empty name/slot')
332
+ continue
333
+ if not name.isprintable():
334
+ rejected += 1
335
+ reasons.append('non-printable name')
336
+ continue
337
+ if name.startswith('__') or name == 'Test Item Name':
338
+ rejected += 1
339
+ reasons.append(f'poison label {name!r}')
340
+ continue
341
+
342
+ try:
343
+ png_bytes = base64.b64decode(item.crop_png_b64)
344
+ except Exception:
345
+ rejected += 1
346
+ reasons.append('b64 decode failed')
347
+ continue
348
+ if not png_bytes.startswith(b'\x89PNG'):
349
+ rejected += 1
350
+ reasons.append('not a PNG')
351
+ continue
352
+ if len(png_bytes) > MAX_CROP_PNG_BYTES:
353
+ rejected += 1
354
+ reasons.append(f'PNG too large ({len(png_bytes)} B)')
355
+ continue
356
+
357
+ err = _check_crop_dims(png_bytes, slot)
358
+ if err:
359
+ rejected += 1
360
+ reasons.append(err)
361
+ continue
362
+
363
+ sha = hashlib.sha256(png_bytes).hexdigest()[:32]
364
+ crop_path = f'{staging_crop}/{sha}.png'
365
+ files_to_upload[crop_path] = png_bytes
366
+
367
+ entry = {
368
+ 'slot': slot,
369
+ 'name': name,
370
+ 'crop_sha256': sha,
371
+ 'date': today,
372
+ }
373
+ if item.ml_name:
374
+ entry['ml_name'] = item.ml_name
375
+ new_entries.append(entry)
376
+ accepted += 1
377
+
378
+ if not new_entries:
379
+ raise HTTPException(400, f'All {rejected} items rejected: {reasons[:3]}')
380
+
381
+ # Merge annotations.jsonl with last-wins per sha (matches client logic).
382
+ merged_jsonl = _merge_annotations_jsonl(install_id, new_entries)
383
+ files_to_upload[staging_anno] = merged_jsonl
384
+
385
+ ok = _hf_upload_files(
386
+ files_to_upload,
387
+ message=f'WARP bulk: {accepted} crops + annotations ({today})',
388
+ repo_id=HF_ICONS_REPO_ID,
389
+ )
390
+ if not ok:
391
+ raise HTTPException(503, 'Storage unavailable, please try later')
392
+
393
+ log.info(f'Bulk crops accepted: install={install_id[:8]} accepted={accepted} rejected={rejected}')
394
+ return {'ok': True, 'accepted': accepted, 'rejected': rejected,
395
+ 'rejected_reasons': reasons[:10] if rejected else []}
396
+
397
+
398
+ @app.post('/upload/screen-types')
399
+ async def upload_screen_types(req: ScreenTypesRequest, request: Request):
400
+ """Accept a batch of screen-type screenshots for one screen_type label."""
401
+ client_ip = _get_client_ip(request)
402
+ if not await _check_and_increment_rate_limit(client_ip):
403
+ raise HTTPException(429, 'Rate limit exceeded. Try again tomorrow.')
404
+
405
+ install_id = req.install_id.strip()
406
+ if not _INSTALL_ID_RE.match(install_id):
407
+ raise HTTPException(400, 'Invalid install_id format')
408
+ stype = req.screen_type.strip()
409
+ if not _SCREEN_TYPE_RE.match(stype):
410
+ raise HTTPException(400, 'Invalid screen_type')
411
+
412
+ base_dir = f'staging/{install_id}/screen_types/{stype}'
413
+ files_to_upload: dict[str, bytes] = {}
414
+ accepted = 0
415
+ rejected = 0
416
+ reasons: list[str] = []
417
+
418
+ for item in req.items:
419
+ try:
420
+ png_bytes = base64.b64decode(item.png_b64)
421
+ except Exception:
422
+ rejected += 1
423
+ reasons.append('b64 decode failed')
424
+ continue
425
+ if not png_bytes.startswith(b'\x89PNG'):
426
+ rejected += 1
427
+ reasons.append('not a PNG')
428
+ continue
429
+ if len(png_bytes) > MAX_SCREEN_PNG_BYTES:
430
+ rejected += 1
431
+ reasons.append(f'PNG too large ({len(png_bytes)} B)')
432
+ continue
433
+
434
+ sha = hashlib.sha256(png_bytes).hexdigest()[:32]
435
+ files_to_upload[f'{base_dir}/{sha}.png'] = png_bytes
436
+ accepted += 1
437
+
438
+ if not files_to_upload:
439
+ raise HTTPException(400, f'All {rejected} items rejected: {reasons[:3]}')
440
+
441
+ ok = _hf_upload_files(
442
+ files_to_upload,
443
+ message=f'WARP screen types: {accepted} {stype} screenshots',
444
+ repo_id=HF_ICONS_REPO_ID,
445
+ )
446
+ if not ok:
447
+ raise HTTPException(503, 'Storage unavailable, please try later')
448
+
449
+ log.info(f'Screen types accepted: install={install_id[:8]} type={stype} accepted={accepted} rejected={rejected}')
450
+ return {'ok': True, 'accepted': accepted, 'rejected': rejected}
451
+
452
+
453
+ @app.post('/upload/anchors')
454
+ async def upload_anchors(req: AnchorsRequest, request: Request):
455
+ """Accept a batch of anchor grids (one file per grid, keyed by sha8)."""
456
+ client_ip = _get_client_ip(request)
457
+ if not await _check_and_increment_rate_limit(client_ip):
458
+ raise HTTPException(429, 'Rate limit exceeded. Try again tomorrow.')
459
+
460
+ install_id = req.install_id.strip()
461
+ if not _INSTALL_ID_RE.match(install_id):
462
+ raise HTTPException(400, 'Invalid install_id format')
463
+
464
+ base_dir = f'staging/{install_id}'
465
+ files_to_upload: dict[str, bytes] = {}
466
+ accepted = 0
467
+ rejected = 0
468
+ reasons: list[str] = []
469
+
470
+ for grid in req.grids:
471
+ slots = grid.slots
472
+ if len(slots) < 3:
473
+ rejected += 1
474
+ reasons.append('fewer than 3 slots')
475
+ continue
476
+ # Each bbox must be [x, y, w, h] (4 floats); guard against malformed payloads.
477
+ if any(len(v) != 4 for v in slots.values()):
478
+ rejected += 1
479
+ reasons.append('bbox not 4-tuple')
480
+ continue
481
+
482
+ payload = {
483
+ 'build_type': grid.build_type,
484
+ 'aspect': grid.aspect,
485
+ 'resolution': grid.resolution,
486
+ 'slots': slots,
487
+ }
488
+ # sort_keys=True must match the client's canonical form so hashes
489
+ # line up and we don't duplicate the same grid as a different file.
490
+ payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=False)
491
+ sha8 = hashlib.sha256(payload_json.encode()).hexdigest()[:8]
492
+ files_to_upload[f'{base_dir}/anchors_grid_{sha8}.json'] = payload_json.encode('utf-8')
493
+ accepted += 1
494
+
495
+ if not files_to_upload:
496
+ raise HTTPException(400, f'All {rejected} grids rejected: {reasons[:3]}')
497
+
498
+ ok = _hf_upload_files(
499
+ files_to_upload,
500
+ message=f'WARP anchors: {accepted} grid entries',
501
+ repo_id=HF_ICONS_REPO_ID,
502
+ )
503
+ if not ok:
504
+ raise HTTPException(503, 'Storage unavailable, please try later')
505
+
506
+ log.info(f'Anchors accepted: install={install_id[:8]} accepted={accepted} rejected={rejected}')
507
+ return {'ok': True, 'accepted': accepted, 'rejected': rejected}
508
+
509
+
510
  @app.post('/webhooks/hf-dataset')
511
  async def hf_dataset_webhook(request: Request):
512
  """
 
604
 
605
  # ── Validation helpers ─────────────────────────────────────────────────────────
606
 
607
+ def _check_crop_dims(png_bytes: bytes, slot: str) -> str | None:
608
+ """Validate crop image dimensions. Returns None if OK, else error message.
609
+
610
+ Text crops (ship_type_/ship_tier_) are wide horizontal bands and use
611
+ relaxed height/width minimums instead of the square icon minimum.
612
+ """
613
+ try:
614
+ nparr = np.frombuffer(png_bytes, np.uint8)
615
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
616
+ if img is None:
617
+ return 'unreadable image'
618
+ h, w = img.shape[:2]
619
+ if any(slot.startswith(p) for p in _TEXT_CROP_PREFIXES):
620
+ if h < MIN_TEXT_CROP_H or w < MIN_TEXT_CROP_W:
621
+ return f'too small ({w}x{h})'
622
+ else:
623
+ if h < MIN_CROP_PX or w < MIN_CROP_PX:
624
+ return f'too small ({w}x{h})'
625
+ return None
626
+ except Exception as e:
627
+ return str(e)
628
+
629
+
630
+ def _merge_annotations_jsonl(install_id: str, new_entries: list[dict]) -> bytes:
631
+ """Fetch existing staging annotations.jsonl, merge with last-wins per sha.
632
+
633
+ Mirrors warp/trainer/sync.py:_append_staging_annotations_to_ops so that
634
+ the central pipeline sees the same per-sha dedup behaviour regardless of
635
+ whether the client uploaded directly (legacy) or via this backend.
636
+ """
637
+ existing_lines = _fetch_staging_annotations(install_id)
638
+
639
+ override_shas = {e.get('crop_sha256', '') for e in new_entries if e.get('crop_sha256')}
640
+ kept: list[str] = []
641
+ for line in existing_lines:
642
+ try:
643
+ sha = json.loads(line).get('crop_sha256', '')
644
+ except Exception:
645
+ kept.append(line)
646
+ continue
647
+ if sha and sha in override_shas:
648
+ continue
649
+ kept.append(line)
650
+
651
+ # Dedup within new_entries themselves (last wins) so a single batch
652
+ # with duplicate sha doesn't write two conflicting labels.
653
+ seen: dict[str, dict] = {}
654
+ for e in new_entries:
655
+ sha = e.get('crop_sha256', '')
656
+ if sha:
657
+ seen[sha] = e
658
+
659
+ combined = kept + [json.dumps(e, ensure_ascii=False) for e in seen.values()]
660
+ return '\n'.join(combined).encode('utf-8')
661
+
662
+
663
  def is_valid_crop(png_bytes: bytes) -> bool:
664
  """Checks if crop is valid (not garbage/too uniform)."""
665
  try:
 
675
 
676
  # ── HF Dataset helpers ────────────────────���────────────────────────────────────
677
 
678
+ def _hf_upload_files(
679
+ files: dict[str, bytes],
680
+ message: str = 'WARP auto-upload',
681
+ repo_id: str | None = None,
682
+ ) -> bool:
683
+ """Upload multiple files to HF Dataset repo atomically.
684
+
685
+ `repo_id` defaults to HF_REPO_ID (knowledge dataset). Pass
686
+ HF_ICONS_REPO_ID for icon-dataset uploads (bulk crops, screen types,
687
+ anchor grids).
688
+ """
689
+ target = repo_id or HF_REPO_ID
690
+ if not HF_TOKEN or not target:
691
+ log.error('HF_TOKEN or repo_id not set')
692
  return False
693
  try:
694
  from huggingface_hub import HfApi, CommitOperationAdd
 
698
  for path, content in files.items()
699
  ]
700
  api.create_commit(
701
+ repo_id=target,
702
  repo_type='dataset',
703
  operations=operations,
704
  commit_message=message,
705
  )
706
  return True
707
  except Exception as e:
708
+ log.error(f'HF atomic upload failed (repo={target}): {e}')
709
  return False
710
 
711
 
712
+ def _fetch_staging_annotations(install_id: str) -> list[str]:
713
+ """Fetch existing staging/<install_id>/annotations.jsonl lines from HF.
714
+
715
+ Returns the raw JSON lines (already stripped of trailing whitespace),
716
+ or [] if the file doesn't exist yet or download fails.
717
+ """
718
+ try:
719
+ from huggingface_hub import hf_hub_download
720
+ local = hf_hub_download(
721
+ repo_id=HF_ICONS_REPO_ID,
722
+ filename=f'staging/{install_id}/annotations.jsonl',
723
+ repo_type='dataset',
724
+ token=HF_TOKEN or None,
725
+ )
726
+ out: list[str] = []
727
+ with open(local, encoding='utf-8') as f:
728
+ for line in f:
729
+ line = line.strip()
730
+ if line:
731
+ out.append(line)
732
+ return out
733
+ except Exception:
734
+ return []
735
+
736
+
737
  def _load_model_version_from_hf() -> dict:
738
  """Download models/model_version.json from HF."""
739
  if not HF_REPO_ID: