Abid Ali Awan Codex commited on
Commit
f619a9b
·
1 Parent(s): 70a95d6

Add deterministic privacy-safe trace dataset

Browse files

Co-Authored-By: Codex <codex@openai.com>

.gitignore CHANGED
@@ -216,3 +216,7 @@ __marimo__/
216
 
217
  # Streamlit
218
  .streamlit/secrets.toml
 
 
 
 
 
216
 
217
  # Streamlit
218
  .streamlit/secrets.toml
219
+
220
+ # Privacy-safe runtime trace shards are uploaded separately.
221
+ traces/pending/
222
+ traces/export/
README.md CHANGED
@@ -68,6 +68,10 @@ OpenAI-compatible endpoint. It does not call OpenAI cloud APIs by default.
68
  | `MODEL_TIMEOUT_SECONDS` | Optional request timeout; default is 180 seconds |
69
  | `MODAL_PROXY_KEY` | Optional Modal proxy authentication key |
70
  | `MODAL_PROXY_SECRET` | Optional Modal proxy authentication secret |
 
 
 
 
71
 
72
  The current defaults are:
73
 
@@ -115,6 +119,37 @@ unsloth/Qwen3.6-27B-MTP-GGUF
115
  All frontend assets are local. The app has no runtime CDN, analytics, OCR, MCP,
116
  or OpenAI Agents SDK. Analysis currently depends on the deployed Modal model.
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  ## Hugging Face Spaces
119
 
120
  Push this repository to a new Gradio Space. The metadata at the top of this
@@ -127,7 +162,8 @@ available as overrides for a future local deployment.
127
 
128
  - Submitted text and images are sent to the configured Modal endpoint and are
129
  not saved by this app.
130
- - The `traces/` directory contains only a placeholder; runtime tracing is off.
 
131
  - Do not upload private personal data unless you trust the Modal deployment.
132
  - No automated result proves that a notice is genuine or fraudulent.
133
  - Image analysis requires a multimodal endpoint with its vision projector.
@@ -145,9 +181,15 @@ docs/
145
  research_notes.md
146
  model_experiment_notes.md
147
  data/
148
- examples.jsonl
149
- sample_inputs/
150
  traces/
 
 
 
 
 
 
151
  static/
152
  index.html
153
  styles.css
@@ -156,9 +198,8 @@ experiments/
156
  modal_qwen36_mtp/
157
  ```
158
 
159
- Existing public and synthetic examples in `data/examples.jsonl` cover courier,
160
- traffic challan, bank, FBR, wallet, job, utility, WhatsApp, and education scam
161
- patterns. Source screenshots are stored under `sample_inputs/`.
162
 
163
  ## Official reporting channels
164
 
 
68
  | `MODEL_TIMEOUT_SECONDS` | Optional request timeout; default is 180 seconds |
69
  | `MODAL_PROXY_KEY` | Optional Modal proxy authentication key |
70
  | `MODAL_PROXY_SECRET` | Optional Modal proxy authentication secret |
71
+ | `HF_TOKEN` | Scoped Hugging Face token used by the background trace uploader |
72
+ | `HF_TRACE_DATASET_REPO` | Trace dataset repo; defaults to `build-small-hackathon/pakistan-notice-helper-traces` |
73
+ | `TRACE_BATCH_SIZE` | Trace records per shard; default is 20 |
74
+ | `TRACE_FLUSH_SECONDS` | Maximum batching delay; default is 60 seconds |
75
 
76
  The current defaults are:
77
 
 
119
  All frontend assets are local. The app has no runtime CDN, analytics, OCR, MCP,
120
  or OpenAI Agents SDK. Analysis currently depends on the deployed Modal model.
121
 
122
+ ## Sharing is Caring: Open Pipeline Traces
123
+
124
+ The app publishes optional privacy-safe backend traces to
125
+ [`build-small-hackathon/pakistan-notice-helper-traces`](https://huggingface.co/datasets/build-small-hackathon/pakistan-notice-helper-traces).
126
+ The checkbox is visible and enabled by default on each request, and users can
127
+ turn it off before submitting.
128
+
129
+ Trace creation is deterministic Python logic and makes no additional model
130
+ request. It records the actual pipeline stages, cache/Modal status, duration
131
+ buckets, fixed signal categories, result counts, and sanitized failure types.
132
+ It never stores raw or redacted messages, screenshots, links, identifiers,
133
+ model explanations, reply text, exceptions, or credentials.
134
+
135
+ Safe records are queued without blocking the response, written in batches of
136
+ 20 or after 60 seconds, and uploaded as unique JSONL shards. Hub failures leave
137
+ the shard pending for a later retry and do not affect scam analysis.
138
+
139
+ Operator commands:
140
+
141
+ ```bash
142
+ python scripts/seed_trace_dataset.py
143
+ python scripts/validate_traces.py data/trace_samples.jsonl
144
+ python scripts/create_trace_dataset.py --dry-run
145
+ python scripts/create_trace_dataset.py
146
+ python scripts/export_pending_traces.py --dry-run
147
+ python scripts/upload_trace_shards.py --dry-run
148
+ ```
149
+
150
+ See [the dataset card](docs/trace_dataset_card.md) for the schema, privacy
151
+ policy, provenance, and limitations.
152
+
153
  ## Hugging Face Spaces
154
 
155
  Push this repository to a new Gradio Space. The metadata at the top of this
 
162
 
163
  - Submitted text and images are sent to the configured Modal endpoint and are
164
  not saved by this app.
165
+ - Public traces contain only allow-listed metadata, buckets, booleans, counts,
166
+ and fixed summaries. Tracing can be disabled per request.
167
  - Do not upload private personal data unless you trust the Modal deployment.
168
  - No automated result proves that a notice is genuine or fraudulent.
169
  - Image analysis requires a multimodal endpoint with its vision projector.
 
181
  research_notes.md
182
  model_experiment_notes.md
183
  data/
184
+ example_assessments.json
185
+ trace_samples.jsonl
186
  traces/
187
+ scripts/
188
+ create_trace_dataset.py
189
+ seed_trace_dataset.py
190
+ validate_traces.py
191
+ export_pending_traces.py
192
+ upload_trace_shards.py
193
  static/
194
  index.html
195
  styles.css
 
198
  modal_qwen36_mtp/
199
  ```
200
 
201
+ The six bundled examples have cached Modal assessments and deterministic seed
202
+ traces. Runtime trace shards are kept out of Git and uploaded separately.
 
203
 
204
  ## Official reporting channels
205
 
app.py CHANGED
@@ -15,6 +15,7 @@ from fastapi.responses import FileResponse
15
  from fastapi.staticfiles import StaticFiles
16
  from gradio import Server
17
  from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
 
18
 
19
  ROOT = Path(__file__).resolve().parent
20
  STATIC_DIR = ROOT / "static"
@@ -176,18 +177,33 @@ def load_example_cache() -> dict[str, dict[str, Any]]:
176
  EXAMPLE_ASSESSMENTS = load_example_cache()
177
 
178
 
179
- def parse_model_json(content: str) -> dict[str, Any]:
 
 
 
180
  candidate = content.strip()
181
  if candidate.startswith("```"):
182
  candidate = re.sub(r"^```(?:json)?\s*", "", candidate, flags=re.I)
183
  candidate = re.sub(r"\s*```$", "", candidate)
 
184
  try:
185
- return normalize_assessment(json.loads(candidate))
186
  except json.JSONDecodeError:
187
  match = re.search(r"\{.*\}", candidate, re.S)
188
  if not match:
189
  raise ValueError("Model did not return JSON.") from None
190
- return normalize_assessment(json.loads(match.group(0)))
 
 
 
 
 
 
 
 
 
 
 
191
 
192
 
193
  def create_model_client() -> tuple[OpenAI, str]:
@@ -215,7 +231,12 @@ def create_model_client() -> tuple[OpenAI, str]:
215
  )
216
 
217
 
218
- def call_model(text: str, image_data_url: str) -> dict[str, Any]:
 
 
 
 
 
219
  client, model_name = create_model_client()
220
  prompt = (
221
  "Assess the following Pakistani notice or message for scam risk. "
@@ -233,8 +254,22 @@ def call_model(text: str, image_data_url: str) -> dict[str, Any]:
233
 
234
  retries = max(1, int(os.getenv("MODEL_MAX_ATTEMPTS", "4")))
235
  retry_delay = max(0.0, float(os.getenv("MODEL_RETRY_DELAY_SECONDS", "5")))
 
 
 
 
 
 
 
 
 
 
236
  for attempt in range(1, retries + 1):
 
 
237
  try:
 
 
238
  completion = client.chat.completions.create(
239
  model=model_name,
240
  messages=[
@@ -253,16 +288,27 @@ def call_model(text: str, image_data_url: str) -> dict[str, Any]:
253
  },
254
  extra_body={"chat_template_kwargs": {"enable_thinking": False}},
255
  )
 
 
 
256
  raw = completion.choices[0].message.content
257
  if not raw:
258
  raise ValueError("Model returned an empty response.")
259
- return parse_model_json(raw)
260
  except APIStatusError as exc:
 
 
 
 
261
  if exc.status_code == 503 and attempt < retries:
262
  time.sleep(retry_delay)
263
  continue
264
  raise
265
  except (APIConnectionError, APITimeoutError):
 
 
 
 
266
  if attempt == retries:
267
  raise
268
  time.sleep(retry_delay)
@@ -271,55 +317,184 @@ def call_model(text: str, image_data_url: str) -> dict[str, Any]:
271
 
272
 
273
  def analyze_notice(
274
- text: str = "", image_data_url: str = "", example_id: str = ""
 
 
 
275
  ) -> dict[str, Any]:
276
  """Analyze supplied text/image using the configured model only."""
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  text = (text or "").strip()
278
  image_data_url = image_data_url or ""
279
  example_id = (example_id or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  if example_id in EXAMPLE_ASSESSMENTS:
281
- return {
282
- "ok": True,
283
- "assessment": dict(EXAMPLE_ASSESSMENTS[example_id]),
284
- "status": model_status(),
285
- "source": "cached_modal_example",
286
- }
287
- if not text and not image_data_url:
288
- return {
289
- "ok": False,
290
- "error": "Paste a message or upload a screenshot to continue.",
291
- "status": model_status(),
292
- }
 
 
293
 
294
  status = model_status()
295
  if not status["connected"]:
296
- return {
297
- "ok": False,
298
- "error": (
299
- "The Modal model requires MODAL_PROXY_KEY and "
300
- "MODAL_PROXY_SECRET. Add them as environment variables or "
301
- "Hugging Face Space secrets."
302
- ),
303
- "status": status,
304
- }
 
 
 
 
 
 
 
305
  try:
306
- result = call_model(text, image_data_url)
307
- return {"ok": True, "assessment": result, "status": status, "source": "model"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  except APIStatusError as exc:
 
 
309
  message = (
310
  "The Modal model rejected the request. Check the proxy credentials."
311
  if exc.status_code in {401, 403}
312
  else f"The Modal model returned HTTP {exc.status_code}. Try again shortly."
313
  )
314
- except (APIConnectionError, APITimeoutError):
 
 
 
 
 
 
 
 
315
  message = "The Modal model is unavailable or still starting. Try again shortly."
 
316
  except (ValueError, RuntimeError):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  message = "The model returned an invalid response. Please try again."
318
- return {
319
- "ok": False,
320
- "error": message,
321
- "status": {**status, "connected": False, "label": "Modal model unavailable"},
322
- }
 
 
 
 
 
 
 
 
 
 
 
323
 
324
 
325
  app = Server()
@@ -328,9 +503,12 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
328
 
329
  @app.api(name="analyze", description="Assess a notice for common scam signals.", concurrency_limit=1)
330
  def analyze_api(
331
- text: str = "", image_data_url: str = "", example_id: str = ""
 
 
 
332
  ) -> dict[str, Any]:
333
- return analyze_notice(text, image_data_url, example_id)
334
 
335
 
336
  @app.api(name="status", description="Return model and privacy status.", queue=False)
@@ -338,6 +516,11 @@ def status_api() -> dict[str, Any]:
338
  return model_status()
339
 
340
 
 
 
 
 
 
341
  @app.get("/", include_in_schema=False)
342
  async def index() -> FileResponse:
343
  return FileResponse(STATIC_DIR / "index.html")
@@ -382,11 +565,11 @@ def run_self_tests() -> None:
382
  }
383
  )
384
  assert inappropriate["reply_draft"] == ""
385
- cached = analyze_notice(example_id="text-bank")
386
  assert cached["ok"] is True
387
  assert cached["source"] == "cached_modal_example"
388
  assert cached["assessment"]["risk_label"] == "Likely scam"
389
- assert analyze_notice("", "")["ok"] is False
390
  try:
391
  normalize_assessment({"risk_label": "Looks normal"})
392
  except ValueError:
@@ -431,6 +614,7 @@ def main() -> int:
431
  if args.test_endpoint:
432
  test_endpoint()
433
  return 0
 
434
  app.launch(server_name=args.host, server_port=args.port)
435
  return 0
436
  except (
 
15
  from fastapi.staticfiles import StaticFiles
16
  from gradio import Server
17
  from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
18
+ from trace_runtime import queue_trace, start_trace_worker, trace_status
19
 
20
  ROOT = Path(__file__).resolve().parent
21
  STATIC_DIR = ROOT / "static"
 
177
  EXAMPLE_ASSESSMENTS = load_example_cache()
178
 
179
 
180
+ def parse_model_json(
181
+ content: str, telemetry: dict[str, Any] | None = None
182
+ ) -> dict[str, Any]:
183
+ telemetry = telemetry if telemetry is not None else {}
184
  candidate = content.strip()
185
  if candidate.startswith("```"):
186
  candidate = re.sub(r"^```(?:json)?\s*", "", candidate, flags=re.I)
187
  candidate = re.sub(r"\s*```$", "", candidate)
188
+ parse_started = time.perf_counter()
189
  try:
190
+ value = json.loads(candidate)
191
  except json.JSONDecodeError:
192
  match = re.search(r"\{.*\}", candidate, re.S)
193
  if not match:
194
  raise ValueError("Model did not return JSON.") from None
195
+ value = json.loads(match.group(0))
196
+ telemetry["parse_ms"] = (time.perf_counter() - parse_started) * 1000
197
+ telemetry["parse_completed"] = True
198
+ normalize_started = time.perf_counter()
199
+ try:
200
+ result = normalize_assessment(value)
201
+ finally:
202
+ telemetry["normalize_ms"] = (
203
+ time.perf_counter() - normalize_started
204
+ ) * 1000
205
+ telemetry["normalize_completed"] = True
206
+ return result
207
 
208
 
209
  def create_model_client() -> tuple[OpenAI, str]:
 
231
  )
232
 
233
 
234
+ def call_model(
235
+ text: str,
236
+ image_data_url: str,
237
+ telemetry: dict[str, Any] | None = None,
238
+ ) -> dict[str, Any]:
239
+ telemetry = telemetry if telemetry is not None else {}
240
  client, model_name = create_model_client()
241
  prompt = (
242
  "Assess the following Pakistani notice or message for scam risk. "
 
254
 
255
  retries = max(1, int(os.getenv("MODEL_MAX_ATTEMPTS", "4")))
256
  retry_delay = max(0.0, float(os.getenv("MODEL_RETRY_DELAY_SECONDS", "5")))
257
+ telemetry.update(
258
+ {
259
+ "modal_called": False,
260
+ "modal_ms": 0.0,
261
+ "retry_count": 0,
262
+ "attempt_count": 0,
263
+ "parse_ms": 0.0,
264
+ "normalize_ms": 0.0,
265
+ }
266
+ )
267
  for attempt in range(1, retries + 1):
268
+ telemetry["attempt_count"] = attempt
269
+ telemetry["retry_count"] = attempt - 1
270
  try:
271
+ request_started = time.perf_counter()
272
+ telemetry["modal_called"] = True
273
  completion = client.chat.completions.create(
274
  model=model_name,
275
  messages=[
 
288
  },
289
  extra_body={"chat_template_kwargs": {"enable_thinking": False}},
290
  )
291
+ telemetry["modal_ms"] += (
292
+ time.perf_counter() - request_started
293
+ ) * 1000
294
  raw = completion.choices[0].message.content
295
  if not raw:
296
  raise ValueError("Model returned an empty response.")
297
+ return parse_model_json(raw, telemetry)
298
  except APIStatusError as exc:
299
+ telemetry["modal_ms"] += max(
300
+ 0.0,
301
+ (time.perf_counter() - request_started) * 1000,
302
+ )
303
  if exc.status_code == 503 and attempt < retries:
304
  time.sleep(retry_delay)
305
  continue
306
  raise
307
  except (APIConnectionError, APITimeoutError):
308
+ telemetry["modal_ms"] += max(
309
+ 0.0,
310
+ (time.perf_counter() - request_started) * 1000,
311
+ )
312
  if attempt == retries:
313
  raise
314
  time.sleep(retry_delay)
 
317
 
318
 
319
  def analyze_notice(
320
+ text: str = "",
321
+ image_data_url: str = "",
322
+ example_id: str = "",
323
+ save_trace: bool = True,
324
  ) -> dict[str, Any]:
325
  """Analyze supplied text/image using the configured model only."""
326
+ request_started = time.perf_counter()
327
+ pipeline_status = {step: "skipped" for step in (
328
+ "receive",
329
+ "validate",
330
+ "cache_lookup",
331
+ "modal_request",
332
+ "parse_json",
333
+ "normalize_result",
334
+ "reply_filter",
335
+ "response",
336
+ )}
337
+ pipeline_ms: dict[str, float] = {}
338
+ pipeline_status["receive"] = "completed"
339
  text = (text or "").strip()
340
  image_data_url = image_data_url or ""
341
  example_id = (example_id or "").strip()
342
+
343
+ def finish(
344
+ response: dict[str, Any],
345
+ *,
346
+ request_source: str,
347
+ telemetry: dict[str, Any] | None = None,
348
+ failure_category: str = "none",
349
+ failure_stage: str = "none",
350
+ ) -> dict[str, Any]:
351
+ telemetry = telemetry or {}
352
+ pipeline_status["response"] = "completed"
353
+ pipeline_ms["response"] = (time.perf_counter() - request_started) * 1000
354
+ if save_trace:
355
+ trace_id, queued = queue_trace(
356
+ text=text,
357
+ image_data_url=image_data_url,
358
+ example_id=example_id,
359
+ request_source=request_source,
360
+ pipeline_status=pipeline_status,
361
+ pipeline_ms=pipeline_ms,
362
+ modal_called=bool(telemetry.get("modal_called", False)),
363
+ modal_ms=float(telemetry.get("modal_ms", 0.0)),
364
+ retry_count=int(telemetry.get("retry_count", 0)),
365
+ assessment=response.get("assessment"),
366
+ failure_category=failure_category,
367
+ failure_stage=failure_stage,
368
+ )
369
+ response["trace"] = {"trace_id": trace_id, "status": queued}
370
+ else:
371
+ response["trace"] = {"trace_id": "", "status": "disabled"}
372
+ return response
373
+
374
+ validation_started = time.perf_counter()
375
+ valid_example = example_id in EXAMPLE_ASSESSMENTS
376
+ if not text and not image_data_url and not valid_example:
377
+ pipeline_status["validate"] = "rejected"
378
+ pipeline_ms["validate"] = (time.perf_counter() - validation_started) * 1000
379
+ return finish(
380
+ {
381
+ "ok": False,
382
+ "error": "Paste a message or upload a screenshot to continue.",
383
+ "status": model_status(),
384
+ },
385
+ request_source="user",
386
+ failure_category="validation_empty",
387
+ failure_stage="validate",
388
+ )
389
+ pipeline_status["validate"] = "completed"
390
+ pipeline_ms["validate"] = (time.perf_counter() - validation_started) * 1000
391
+
392
+ cache_started = time.perf_counter()
393
  if example_id in EXAMPLE_ASSESSMENTS:
394
+ pipeline_status["cache_lookup"] = "hit"
395
+ pipeline_ms["cache_lookup"] = (time.perf_counter() - cache_started) * 1000
396
+ pipeline_status["reply_filter"] = "completed"
397
+ return finish(
398
+ {
399
+ "ok": True,
400
+ "assessment": dict(EXAMPLE_ASSESSMENTS[example_id]),
401
+ "status": model_status(),
402
+ "source": "cached_modal_example",
403
+ },
404
+ request_source="cached_modal_example",
405
+ )
406
+ pipeline_status["cache_lookup"] = "miss"
407
+ pipeline_ms["cache_lookup"] = (time.perf_counter() - cache_started) * 1000
408
 
409
  status = model_status()
410
  if not status["connected"]:
411
+ pipeline_status["modal_request"] = "skipped"
412
+ return finish(
413
+ {
414
+ "ok": False,
415
+ "error": (
416
+ "The Modal model requires MODAL_PROXY_KEY and "
417
+ "MODAL_PROXY_SECRET. Add them as environment variables or "
418
+ "Hugging Face Space secrets."
419
+ ),
420
+ "status": status,
421
+ },
422
+ request_source="user",
423
+ failure_category="credentials_missing",
424
+ failure_stage="modal_request",
425
+ )
426
+ telemetry: dict[str, Any] = {}
427
  try:
428
+ result = call_model(text, image_data_url, telemetry)
429
+ pipeline_status["modal_request"] = "completed"
430
+ pipeline_status["parse_json"] = "completed"
431
+ pipeline_status["normalize_result"] = "completed"
432
+ pipeline_status["reply_filter"] = "completed"
433
+ pipeline_ms["modal_request"] = float(telemetry.get("modal_ms", 0.0))
434
+ pipeline_ms["parse_json"] = float(telemetry.get("parse_ms", 0.0))
435
+ pipeline_ms["normalize_result"] = float(telemetry.get("normalize_ms", 0.0))
436
+ return finish(
437
+ {
438
+ "ok": True,
439
+ "assessment": result,
440
+ "status": status,
441
+ "source": "model",
442
+ },
443
+ request_source="user",
444
+ telemetry=telemetry,
445
+ )
446
  except APIStatusError as exc:
447
+ pipeline_status["modal_request"] = "failed"
448
+ pipeline_ms["modal_request"] = float(telemetry.get("modal_ms", 0.0))
449
  message = (
450
  "The Modal model rejected the request. Check the proxy credentials."
451
  if exc.status_code in {401, 403}
452
  else f"The Modal model returned HTTP {exc.status_code}. Try again shortly."
453
  )
454
+ failure_category = "http_auth" if exc.status_code in {401, 403} else "http_error"
455
+ except APITimeoutError:
456
+ pipeline_status["modal_request"] = "failed"
457
+ pipeline_ms["modal_request"] = float(telemetry.get("modal_ms", 0.0))
458
+ message = "The Modal model is unavailable or still starting. Try again shortly."
459
+ failure_category = "timeout"
460
+ except APIConnectionError:
461
+ pipeline_status["modal_request"] = "failed"
462
+ pipeline_ms["modal_request"] = float(telemetry.get("modal_ms", 0.0))
463
  message = "The Modal model is unavailable or still starting. Try again shortly."
464
+ failure_category = "connection_error"
465
  except (ValueError, RuntimeError):
466
+ pipeline_status["modal_request"] = (
467
+ "completed" if telemetry.get("modal_called") else "skipped"
468
+ )
469
+ pipeline_ms["modal_request"] = float(telemetry.get("modal_ms", 0.0))
470
+ pipeline_ms["parse_json"] = float(telemetry.get("parse_ms", 0.0))
471
+ pipeline_ms["normalize_result"] = float(
472
+ telemetry.get("normalize_ms", 0.0)
473
+ )
474
+ if telemetry.get("parse_completed"):
475
+ pipeline_status["parse_json"] = "completed"
476
+ pipeline_status["normalize_result"] = "failed"
477
+ failure_stage = "normalize_result"
478
+ else:
479
+ pipeline_status["parse_json"] = "failed"
480
+ failure_stage = "parse_json"
481
  message = "The model returned an invalid response. Please try again."
482
+ failure_category = "invalid_model_output"
483
+ return finish(
484
+ {
485
+ "ok": False,
486
+ "error": message,
487
+ "status": {**status, "connected": False, "label": "Modal model unavailable"},
488
+ },
489
+ request_source="user",
490
+ telemetry=telemetry,
491
+ failure_category=failure_category,
492
+ failure_stage=(
493
+ failure_stage
494
+ if failure_category == "invalid_model_output"
495
+ else "modal_request"
496
+ ),
497
+ )
498
 
499
 
500
  app = Server()
 
503
 
504
  @app.api(name="analyze", description="Assess a notice for common scam signals.", concurrency_limit=1)
505
  def analyze_api(
506
+ text: str = "",
507
+ image_data_url: str = "",
508
+ example_id: str = "",
509
+ save_trace: bool = True,
510
  ) -> dict[str, Any]:
511
+ return analyze_notice(text, image_data_url, example_id, save_trace)
512
 
513
 
514
  @app.api(name="status", description="Return model and privacy status.", queue=False)
 
516
  return model_status()
517
 
518
 
519
+ @app.api(name="trace_status", description="Return privacy-safe trace queue status.", queue=False)
520
+ def trace_status_api() -> dict[str, Any]:
521
+ return trace_status()
522
+
523
+
524
  @app.get("/", include_in_schema=False)
525
  async def index() -> FileResponse:
526
  return FileResponse(STATIC_DIR / "index.html")
 
565
  }
566
  )
567
  assert inappropriate["reply_draft"] == ""
568
+ cached = analyze_notice(example_id="text-bank", save_trace=False)
569
  assert cached["ok"] is True
570
  assert cached["source"] == "cached_modal_example"
571
  assert cached["assessment"]["risk_label"] == "Likely scam"
572
+ assert analyze_notice("", "", save_trace=False)["ok"] is False
573
  try:
574
  normalize_assessment({"risk_label": "Looks normal"})
575
  except ValueError:
 
614
  if args.test_endpoint:
615
  test_endpoint()
616
  return 0
617
+ start_trace_worker()
618
  app.launch(server_name=args.host, server_port=args.port)
619
  return 0
620
  except (
data/trace_samples.jsonl ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {"app_commit": "unknown", "cache": {"example_id": "text-courier", "hit": true}, "failure": {"category": "none", "stage": "none"}, "input": {"category": "courier", "image_size_bucket": "none", "language_hint": "latin_script", "safe_summary": "Courier-style text input with link, urgency, payment, courier signals", "signals": {"account_threat": false, "challan": false, "cnic": false, "courier": true, "credentials": false, "link": true, "otp": false, "payment": true, "refund_or_prize": false, "urgency": true}, "text_byte_bucket": "1-160", "text_character_bucket": "1-160", "type": "text"}, "modal": {"called": false, "latency_bucket": "0-1ms", "model_family": "qwen3.6-27b-mtp", "outcome": "not_called", "retry_count": 0}, "pipeline_steps": [{"duration_bucket": "0-1ms", "status": "completed", "step": "receive"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "validate"}, {"duration_bucket": "0-1ms", "status": "hit", "step": "cache_lookup"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "modal_request"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "parse_json"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "normalize_result"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "reply_filter"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "response"}], "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "request_source": "cached_modal_example", "result": {"red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "schema_version": "1.0", "timestamp": "2026-06-07T06:08:05.356371+00:00", "trace_id": "72c1dd56-9ae4-42d2-a8cd-7333f48d195e"}
2
+ {"app_commit": "unknown", "cache": {"example_id": "text-fbr", "hit": true}, "failure": {"category": "none", "stage": "none"}, "input": {"category": "fbr", "image_size_bucket": "none", "language_hint": "latin_script", "safe_summary": "FBR-style text input with CNIC, credential, urgency, payment signals", "signals": {"account_threat": false, "challan": false, "cnic": true, "courier": false, "credentials": true, "link": false, "otp": false, "payment": true, "refund_or_prize": true, "urgency": true}, "text_byte_bucket": "1-160", "text_character_bucket": "1-160", "type": "text"}, "modal": {"called": false, "latency_bucket": "0-1ms", "model_family": "qwen3.6-27b-mtp", "outcome": "not_called", "retry_count": 0}, "pipeline_steps": [{"duration_bucket": "0-1ms", "status": "completed", "step": "receive"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "validate"}, {"duration_bucket": "0-1ms", "status": "hit", "step": "cache_lookup"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "modal_request"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "parse_json"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "normalize_result"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "reply_filter"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "response"}], "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "request_source": "cached_modal_example", "result": {"red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "schema_version": "1.0", "timestamp": "2026-06-07T06:08:05.357519+00:00", "trace_id": "12bb3cf0-43e1-464b-b004-e9948cc4ed2a"}
3
+ {"app_commit": "unknown", "cache": {"example_id": "text-bank", "hit": true}, "failure": {"category": "none", "stage": "none"}, "input": {"category": "bank", "image_size_bucket": "none", "language_hint": "latin_script", "safe_summary": "Bank-style text input with OTP, urgency, account-threat signals", "signals": {"account_threat": true, "challan": false, "cnic": false, "courier": false, "credentials": false, "link": false, "otp": true, "payment": false, "refund_or_prize": false, "urgency": true}, "text_byte_bucket": "1-160", "text_character_bucket": "1-160", "type": "text"}, "modal": {"called": false, "latency_bucket": "0-1ms", "model_family": "qwen3.6-27b-mtp", "outcome": "not_called", "retry_count": 0}, "pipeline_steps": [{"duration_bucket": "0-1ms", "status": "completed", "step": "receive"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "validate"}, {"duration_bucket": "0-1ms", "status": "hit", "step": "cache_lookup"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "modal_request"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "parse_json"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "normalize_result"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "reply_filter"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "response"}], "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "request_source": "cached_modal_example", "result": {"red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 5}, "schema_version": "1.0", "timestamp": "2026-06-07T06:08:05.357666+00:00", "trace_id": "80403716-5ed5-4ee6-8432-e0677f3a75c4"}
4
+ {"app_commit": "unknown", "cache": {"example_id": "image-courier", "hit": true}, "failure": {"category": "none", "stage": "none"}, "input": {"category": "courier", "image_size_bucket": "up-to-100KB", "language_hint": "unknown", "safe_summary": "Courier-style image input with link, urgency, courier signals", "signals": {"account_threat": false, "challan": false, "cnic": false, "courier": true, "credentials": false, "link": true, "otp": false, "payment": false, "refund_or_prize": false, "urgency": true}, "text_byte_bucket": "empty", "text_character_bucket": "empty", "type": "image"}, "modal": {"called": false, "latency_bucket": "0-1ms", "model_family": "qwen3.6-27b-mtp", "outcome": "not_called", "retry_count": 0}, "pipeline_steps": [{"duration_bucket": "0-1ms", "status": "completed", "step": "receive"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "validate"}, {"duration_bucket": "0-1ms", "status": "hit", "step": "cache_lookup"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "modal_request"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "parse_json"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "normalize_result"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "reply_filter"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "response"}], "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "request_source": "cached_modal_example", "result": {"red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "schema_version": "1.0", "timestamp": "2026-06-07T06:08:05.357884+00:00", "trace_id": "2b7fbebf-43dd-4a5a-bf45-06059f3dc4ff"}
5
+ {"app_commit": "unknown", "cache": {"example_id": "image-mobile", "hit": true}, "failure": {"category": "none", "stage": "none"}, "input": {"category": "marketplace", "image_size_bucket": "500KB-2MB", "language_hint": "unknown", "safe_summary": "Marketplace-style image input with credential signals", "signals": {"account_threat": false, "challan": false, "cnic": false, "courier": false, "credentials": true, "link": false, "otp": false, "payment": false, "refund_or_prize": false, "urgency": false}, "text_byte_bucket": "empty", "text_character_bucket": "empty", "type": "image"}, "modal": {"called": false, "latency_bucket": "0-1ms", "model_family": "qwen3.6-27b-mtp", "outcome": "not_called", "retry_count": 0}, "pipeline_steps": [{"duration_bucket": "0-1ms", "status": "completed", "step": "receive"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "validate"}, {"duration_bucket": "0-1ms", "status": "hit", "step": "cache_lookup"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "modal_request"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "parse_json"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "normalize_result"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "reply_filter"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "response"}], "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "request_source": "cached_modal_example", "result": {"red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "schema_version": "1.0", "timestamp": "2026-06-07T06:08:05.358366+00:00", "trace_id": "2d890292-43af-4e83-9afd-8069df37dc16"}
6
+ {"app_commit": "unknown", "cache": {"example_id": "image-traffic", "hit": true}, "failure": {"category": "none", "stage": "none"}, "input": {"category": "traffic_challan", "image_size_bucket": "up-to-100KB", "language_hint": "unknown", "safe_summary": "Traffic-challan-style image input with link, urgency, payment, challan signals", "signals": {"account_threat": false, "challan": true, "cnic": false, "courier": false, "credentials": false, "link": true, "otp": false, "payment": true, "refund_or_prize": false, "urgency": true}, "text_byte_bucket": "empty", "text_character_bucket": "empty", "type": "image"}, "modal": {"called": false, "latency_bucket": "0-1ms", "model_family": "qwen3.6-27b-mtp", "outcome": "not_called", "retry_count": 0}, "pipeline_steps": [{"duration_bucket": "0-1ms", "status": "completed", "step": "receive"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "validate"}, {"duration_bucket": "0-1ms", "status": "hit", "step": "cache_lookup"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "modal_request"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "parse_json"}, {"duration_bucket": "0-1ms", "status": "skipped", "step": "normalize_result"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "reply_filter"}, {"duration_bucket": "0-1ms", "status": "completed", "step": "response"}], "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "request_source": "cached_modal_example", "result": {"red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "schema_version": "1.0", "timestamp": "2026-06-07T06:08:05.358581+00:00", "trace_id": "872ba35e-76b8-41e5-9cfc-052656cca1ff"}
docs/trace_dataset_card.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-4.0
3
+ pretty_name: Pakistan Notice Helper Privacy-Safe Pipeline Traces
4
+ task_categories:
5
+ - text-classification
6
+ language:
7
+ - en
8
+ - ur
9
+ tags:
10
+ - safety
11
+ - scams
12
+ - pakistan
13
+ - pipeline-traces
14
+ - privacy
15
+ configs:
16
+ - config_name: default
17
+ data_files:
18
+ - split: train
19
+ path: data/**/*.jsonl
20
+ ---
21
+
22
+ # Pakistan Notice Helper Privacy-Safe Pipeline Traces
23
+
24
+ ## Purpose
25
+
26
+ This dataset shows how Pakistan Notice Helper processes scam-check requests.
27
+ It contains deterministic application-pipeline traces, not hidden model
28
+ reasoning and not autonomous-agent trajectories.
29
+
30
+ The application uses a Modal-hosted Qwen model for normal assessments. Creating
31
+ a trace never calls an AI model. Traces only observe the existing request path
32
+ and convert it into allow-listed categories, booleans, buckets, and counts.
33
+
34
+ ## Pipeline
35
+
36
+ Each record follows these ordered stages:
37
+
38
+ 1. `receive`
39
+ 2. `validate`
40
+ 3. `cache_lookup`
41
+ 4. `modal_request`
42
+ 5. `parse_json`
43
+ 6. `normalize_result`
44
+ 7. `reply_filter`
45
+ 8. `response`
46
+
47
+ Stages may be completed, skipped, rejected, failed, hit, or miss.
48
+
49
+ ## Fields
50
+
51
+ - Trace identity: schema version, random trace ID, UTC timestamp, app commit
52
+ - Input profile: type, size buckets, category, script/language hint
53
+ - Deterministic signals: OTP, CNIC, credentials, link, urgency, payment,
54
+ refund/prize, courier, challan, and account threat
55
+ - Fixed-template safe summary
56
+ - Cache and Modal-call metadata
57
+ - Pipeline status and duration buckets
58
+ - Final risk label and output item counts
59
+ - Sanitized failure category and stage
60
+ - Explicit privacy flags
61
+
62
+ ## Privacy
63
+
64
+ The dataset never stores:
65
+
66
+ - Raw or redacted message text
67
+ - Screenshots, image bytes, or base64
68
+ - URLs, phone numbers, CNICs, names, addresses, account/card numbers, or
69
+ tracking numbers
70
+ - Model explanations, red flags, safe-step text, reply drafts, or raw model
71
+ output
72
+ - Exceptions, credentials, tokens, or endpoint headers
73
+
74
+ Summaries use fixed templates. Regex detection happens transiently in memory.
75
+ Users see a checked trace disclosure in the app and may opt out before each
76
+ request.
77
+
78
+ ## Provenance
79
+
80
+ Seed traces represent the six public examples bundled with Pakistan Notice
81
+ Helper. Runtime traces may represent successful, cached, rejected, or failed
82
+ requests. A trace reports whether the existing Modal request occurred, but
83
+ trace generation itself does not invoke the model.
84
+
85
+ ## Limitations
86
+
87
+ - Regex signals and category detection are approximate.
88
+ - Duration and input sizes are deliberately bucketed.
89
+ - The dataset cannot reproduce original messages or screenshots.
90
+ - A risk label is safety guidance, not official verification.
91
+ - Records with `app_commit: unknown` were generated where commit metadata was
92
+ unavailable.
93
+
94
+ ## Links
95
+
96
+ - App: https://huggingface.co/spaces/build-small-hackathon/pakistan-notice-helper
97
+ - Source: https://github.com/kingabzpro/pakistan-notice-helper
98
+
99
+ ## License
100
+
101
+ CC BY 4.0.
requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  gradio==6.15.1
2
  openai==2.33.0
 
 
1
  gradio==6.15.1
2
  openai==2.33.0
3
+ huggingface_hub==1.18.0
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Trace dataset administration scripts."""
scripts/create_trace_dataset.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create and initialize the public Hugging Face trace dataset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from huggingface_hub import HfApi
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ DEFAULT_REPO = "build-small-hackathon/pakistan-notice-helper-traces"
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(description=__doc__)
17
+ parser.add_argument(
18
+ "--repo-id",
19
+ default=os.getenv("HF_TRACE_DATASET_REPO", DEFAULT_REPO),
20
+ )
21
+ parser.add_argument("--dry-run", action="store_true")
22
+ args = parser.parse_args()
23
+ files = {
24
+ ROOT / "docs" / "trace_dataset_card.md": "README.md",
25
+ ROOT / "data" / "trace_samples.jsonl": "data/seed/trace_samples.jsonl",
26
+ }
27
+ if args.dry_run:
28
+ print(f"Would create public dataset: {args.repo_id}")
29
+ for local, remote in files.items():
30
+ print(f"Would upload {local} -> {remote}")
31
+ return 0
32
+ api = HfApi(token=os.getenv("HF_TOKEN") or None)
33
+ api.create_repo(
34
+ repo_id=args.repo_id,
35
+ repo_type="dataset",
36
+ private=False,
37
+ exist_ok=True,
38
+ )
39
+ for local, remote in files.items():
40
+ api.upload_file(
41
+ path_or_fileobj=str(local),
42
+ path_in_repo=remote,
43
+ repo_id=args.repo_id,
44
+ repo_type="dataset",
45
+ commit_message=f"Add {remote}",
46
+ )
47
+ print(f"Initialized https://huggingface.co/datasets/{args.repo_id}")
48
+ return 0
49
+
50
+
51
+ if __name__ == "__main__":
52
+ raise SystemExit(main())
scripts/export_pending_traces.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export validated pending trace shards into one JSONL file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from trace_runtime import PENDING_DIR, validate_trace
14
+
15
+
16
+ def main() -> int:
17
+ parser = argparse.ArgumentParser(description=__doc__)
18
+ parser.add_argument(
19
+ "--output",
20
+ type=Path,
21
+ default=ROOT / "traces" / "export" / "trace_export.jsonl",
22
+ )
23
+ parser.add_argument("--dry-run", action="store_true")
24
+ args = parser.parse_args()
25
+ records = []
26
+ for path in sorted(PENDING_DIR.glob("*.jsonl")):
27
+ for line in path.read_text(encoding="utf-8").splitlines():
28
+ if not line:
29
+ continue
30
+ record = json.loads(line)
31
+ errors = validate_trace(record)
32
+ if errors:
33
+ raise RuntimeError(f"{path}: {'; '.join(errors)}")
34
+ records.append(record)
35
+ content = "".join(
36
+ json.dumps(record, sort_keys=True, ensure_ascii=True) + "\n"
37
+ for record in records
38
+ )
39
+ if args.dry_run:
40
+ print(content, end="")
41
+ return 0
42
+ args.output.parent.mkdir(parents=True, exist_ok=True)
43
+ args.output.write_text(content, encoding="utf-8")
44
+ print(f"Exported {len(records)} traces to {args.output}")
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ raise SystemExit(main())
scripts/seed_trace_dataset.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate deterministic seed traces for the six built-in examples."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from trace_runtime import PIPELINE_STEPS, build_trace_record, validate_trace
14
+
15
+ TEXT_EXAMPLES = {
16
+ "text-courier": (
17
+ "PAKISTAN POST: Your parcel address is incomplete. Pay Rs. 85 today "
18
+ "at http://pakpost-delivery.xyz or the parcel will be destroyed."
19
+ ),
20
+ "text-fbr": (
21
+ "FBR REFUND: You are eligible for Rs 42,500. Submit your CNIC and bank "
22
+ "card details at the link today to receive payment."
23
+ ),
24
+ "text-bank": (
25
+ "HBL Security: Your account will be suspended. Share the OTP sent to "
26
+ "your phone with our support team immediately."
27
+ ),
28
+ }
29
+ IMAGE_EXAMPLES = {
30
+ "image-courier": ROOT / "static" / "example-courier.jpeg",
31
+ "image-mobile": ROOT / "static" / "example-mobile.png",
32
+ "image-traffic": ROOT / "static" / "example-trafic.png",
33
+ }
34
+
35
+
36
+ def build_seed_records() -> list[dict]:
37
+ assessments = json.loads(
38
+ (ROOT / "data" / "example_assessments.json").read_text(encoding="utf-8")
39
+ )["examples"]
40
+ pipeline_status = {
41
+ step: (
42
+ "completed"
43
+ if step
44
+ in {
45
+ "receive",
46
+ "validate",
47
+ "reply_filter",
48
+ "response",
49
+ }
50
+ else "hit"
51
+ if step == "cache_lookup"
52
+ else "skipped"
53
+ )
54
+ for step in PIPELINE_STEPS
55
+ }
56
+ records = []
57
+ for example_id, assessment in assessments.items():
58
+ text = TEXT_EXAMPLES.get(example_id, "")
59
+ image_path = IMAGE_EXAMPLES.get(example_id)
60
+ image_placeholder = ""
61
+ if image_path:
62
+ image_placeholder = "x" * int(image_path.stat().st_size * 4 / 3)
63
+ record = build_trace_record(
64
+ text=text,
65
+ image_data_url=image_placeholder,
66
+ example_id=example_id,
67
+ request_source="cached_modal_example",
68
+ pipeline_status=pipeline_status,
69
+ pipeline_ms={},
70
+ modal_called=False,
71
+ modal_ms=0,
72
+ retry_count=0,
73
+ assessment=assessment,
74
+ )
75
+ errors = validate_trace(record)
76
+ if errors:
77
+ raise RuntimeError(f"{example_id}: {'; '.join(errors)}")
78
+ records.append(record)
79
+ return records
80
+
81
+
82
+ def main() -> int:
83
+ parser = argparse.ArgumentParser(description=__doc__)
84
+ parser.add_argument(
85
+ "--output",
86
+ type=Path,
87
+ default=ROOT / "data" / "trace_samples.jsonl",
88
+ )
89
+ parser.add_argument("--dry-run", action="store_true")
90
+ args = parser.parse_args()
91
+ records = build_seed_records()
92
+ content = "".join(
93
+ json.dumps(record, sort_keys=True, ensure_ascii=True) + "\n"
94
+ for record in records
95
+ )
96
+ if args.dry_run:
97
+ print(content, end="")
98
+ return 0
99
+ args.output.parent.mkdir(parents=True, exist_ok=True)
100
+ args.output.write_text(content, encoding="utf-8")
101
+ print(f"Wrote {len(records)} traces to {args.output}")
102
+ return 0
103
+
104
+
105
+ if __name__ == "__main__":
106
+ raise SystemExit(main())
scripts/upload_trace_shards.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate and upload pending privacy-safe trace shards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from huggingface_hub import HfApi
12
+
13
+ ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(ROOT))
15
+
16
+ from scripts.validate_traces import validate_file
17
+ from trace_runtime import DATASET_REPO, PENDING_DIR
18
+
19
+
20
+ def main() -> int:
21
+ parser = argparse.ArgumentParser(description=__doc__)
22
+ parser.add_argument(
23
+ "--repo-id",
24
+ default=os.getenv("HF_TRACE_DATASET_REPO", DATASET_REPO),
25
+ )
26
+ parser.add_argument("--dry-run", action="store_true")
27
+ parser.add_argument("--keep", action="store_true")
28
+ args = parser.parse_args()
29
+ paths = sorted(PENDING_DIR.glob("*.jsonl"))
30
+ if not paths:
31
+ print("No pending trace shards.")
32
+ return 0
33
+ api = HfApi(token=os.getenv("HF_TOKEN") or None)
34
+ date_path = datetime.now(timezone.utc).strftime("%Y/%m/%d")
35
+ for path in paths:
36
+ count, errors = validate_file(path)
37
+ if errors:
38
+ raise RuntimeError("\n".join(errors))
39
+ remote = f"data/{date_path}/{path.name}"
40
+ if args.dry_run:
41
+ print(f"Would upload {count} records: {path} -> {remote}")
42
+ continue
43
+ api.upload_file(
44
+ path_or_fileobj=str(path),
45
+ path_in_repo=remote,
46
+ repo_id=args.repo_id,
47
+ repo_type="dataset",
48
+ commit_message=f"Add privacy-safe trace shard {path.name}",
49
+ )
50
+ if not args.keep:
51
+ path.unlink()
52
+ print(f"Uploaded {count} records to {remote}")
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
scripts/validate_traces.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate privacy-safe trace JSONL files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from trace_runtime import validate_trace
14
+
15
+
16
+ def validate_file(path: Path) -> tuple[int, list[str]]:
17
+ count = 0
18
+ errors: list[str] = []
19
+ for line_number, line in enumerate(
20
+ path.read_text(encoding="utf-8").splitlines(),
21
+ start=1,
22
+ ):
23
+ if not line.strip():
24
+ continue
25
+ count += 1
26
+ try:
27
+ record = json.loads(line)
28
+ except json.JSONDecodeError as exc:
29
+ errors.append(f"{path}:{line_number}: invalid JSON: {exc}")
30
+ continue
31
+ for error in validate_trace(record):
32
+ errors.append(f"{path}:{line_number}: {error}")
33
+ return count, errors
34
+
35
+
36
+ def main() -> int:
37
+ parser = argparse.ArgumentParser(description=__doc__)
38
+ parser.add_argument("paths", nargs="*", type=Path)
39
+ args = parser.parse_args()
40
+ paths = args.paths or [ROOT / "data" / "trace_samples.jsonl"]
41
+ total = 0
42
+ all_errors: list[str] = []
43
+ for path in paths:
44
+ if not path.exists():
45
+ all_errors.append(f"{path}: file does not exist")
46
+ continue
47
+ count, errors = validate_file(path)
48
+ total += count
49
+ all_errors.extend(errors)
50
+ if all_errors:
51
+ print("\n".join(all_errors), file=sys.stderr)
52
+ return 1
53
+ print(f"Validated {total} trace records.")
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ raise SystemExit(main())
static/app.js CHANGED
@@ -15,6 +15,7 @@ const elements = {
15
  source: document.querySelector("#resultSource"),
16
  uploadHint: document.querySelector("#uploadHint"),
17
  textHint: document.querySelector("#textHint"),
 
18
  };
19
 
20
  let imageDataUrl = "";
@@ -250,7 +251,7 @@ elements.form.addEventListener("submit", async (event) => {
250
  const submittedImage = activeExampleId ? "" : imageDataUrl;
251
  renderResult(await callGradioApi(
252
  "analyze",
253
- [elements.text.value, submittedImage, activeExampleId],
254
  ));
255
  } catch (error) {
256
  showError(error.message || "The request could not be completed.");
 
15
  source: document.querySelector("#resultSource"),
16
  uploadHint: document.querySelector("#uploadHint"),
17
  textHint: document.querySelector("#textHint"),
18
+ saveTrace: document.querySelector("#saveTrace"),
19
  };
20
 
21
  let imageDataUrl = "";
 
251
  const submittedImage = activeExampleId ? "" : imageDataUrl;
252
  renderResult(await callGradioApi(
253
  "analyze",
254
+ [elements.text.value, submittedImage, activeExampleId, elements.saveTrace.checked],
255
  ));
256
  } catch (error) {
257
  showError(error.message || "The request could not be completed.");
static/index.html CHANGED
@@ -74,6 +74,13 @@
74
  </div>
75
 
76
  <div id="formError" class="form-error" role="alert"></div>
 
 
 
 
 
 
 
77
  <div class="form-actions">
78
  <button id="analyzeButton" class="primary-button" type="submit">
79
  <span class="button-label">Check this notice</span>
 
74
  </div>
75
 
76
  <div id="formError" class="form-error" role="alert"></div>
77
+ <label class="trace-consent" for="saveTrace">
78
+ <input id="saveTrace" type="checkbox" checked>
79
+ <span>
80
+ <strong>Publish privacy-safe trace</strong>
81
+ <small>No raw message, screenshot, link, identifier, or model text is stored.</small>
82
+ </span>
83
+ </label>
84
  <div class="form-actions">
85
  <button id="analyzeButton" class="primary-button" type="submit">
86
  <span class="button-label">Check this notice</span>
static/styles.css CHANGED
@@ -136,6 +136,14 @@ textarea:disabled { opacity: .45; background: #f0f5f2; cursor: not-allowed; }
136
  .form-actions { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 26px; }
137
  .form-error { display: none; margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #fff0ef; color: #8d2722; font-size: 13px; }
138
  .form-error.visible { display: block; }
 
 
 
 
 
 
 
 
139
 
140
  .mode-hint {
141
  display: none; margin-top: 10px; padding: 8px 14px; border-radius: 10px;
 
136
  .form-actions { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 26px; }
137
  .form-error { display: none; margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #fff0ef; color: #8d2722; font-size: 13px; }
138
  .form-error.visible { display: block; }
139
+ .trace-consent {
140
+ margin-top: 18px; padding: 13px 15px; display: flex; align-items: flex-start; gap: 11px;
141
+ border: 1px solid var(--line); border-radius: 14px; background: #f8fbf9; cursor: pointer;
142
+ }
143
+ .trace-consent input { width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--green-700); }
144
+ .trace-consent span, .trace-consent strong, .trace-consent small { display: block; }
145
+ .trace-consent strong { color: var(--green-950); font-size: 13px; }
146
+ .trace-consent small { margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.45; }
147
 
148
  .mode-hint {
149
  display: none; margin-top: 10px; padding: 8px 14px; border-radius: 10px;
tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Test package."""
tests/test_tracing.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for deterministic privacy-safe tracing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ import threading
8
+ import time
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest.mock import patch
12
+
13
+ import httpx
14
+ from openai import APIStatusError, APITimeoutError
15
+
16
+ import app
17
+ import trace_runtime
18
+
19
+
20
+ class TraceTests(unittest.TestCase):
21
+ def sample_record(self) -> dict:
22
+ return trace_runtime.build_trace_record(
23
+ text=(
24
+ "Ali call +92 300 1234567 CNIC 35202-1234567-1 "
25
+ "card 4111111111111111 https://private.example/a "
26
+ "account PK00TEST address House 10 tracking ABC123"
27
+ ),
28
+ image_data_url="data:image/png;base64,PRIVATE_IMAGE_BYTES",
29
+ example_id="",
30
+ request_source="user",
31
+ pipeline_status={step: "completed" for step in trace_runtime.PIPELINE_STEPS},
32
+ pipeline_ms={},
33
+ modal_called=True,
34
+ modal_ms=120,
35
+ retry_count=0,
36
+ assessment={
37
+ "risk_label": "Likely scam",
38
+ "simple_explanation": "PRIVATE MODEL EXPLANATION",
39
+ "red_flags": ["PRIVATE FLAG"],
40
+ "safe_next_steps": ["PRIVATE STEP"],
41
+ "reply_draft": "PRIVATE REPLY",
42
+ },
43
+ )
44
+
45
+ def test_trace_has_no_private_canaries(self) -> None:
46
+ serialized = json.dumps(self.sample_record())
47
+ for canary in (
48
+ "Ali",
49
+ "+92 300 1234567",
50
+ "35202-1234567-1",
51
+ "4111111111111111",
52
+ "private.example",
53
+ "PK00TEST",
54
+ "House 10",
55
+ "ABC123",
56
+ "PRIVATE_IMAGE_BYTES",
57
+ "PRIVATE MODEL EXPLANATION",
58
+ "PRIVATE FLAG",
59
+ "PRIVATE STEP",
60
+ "PRIVATE REPLY",
61
+ ):
62
+ self.assertNotIn(canary, serialized)
63
+
64
+ def test_trace_validation_and_performance(self) -> None:
65
+ started = time.perf_counter()
66
+ records = [self.sample_record() for _ in range(200)]
67
+ elapsed_ms = (time.perf_counter() - started) * 1000 / len(records)
68
+ self.assertLess(elapsed_ms, 10)
69
+ self.assertFalse(trace_runtime.validate_trace(records[0]))
70
+
71
+ def test_opt_out_does_not_queue_trace(self) -> None:
72
+ with patch("app.queue_trace") as queue_mock:
73
+ result = app.analyze_notice("", "", save_trace=False)
74
+ queue_mock.assert_not_called()
75
+ self.assertEqual(result["trace"]["status"], "disabled")
76
+
77
+ def test_cached_trace_does_not_call_model(self) -> None:
78
+ with patch("app.call_model") as model_mock, patch(
79
+ "app.queue_trace",
80
+ return_value=("trace-id", "queued"),
81
+ ):
82
+ result = app.analyze_notice(example_id="text-bank")
83
+ model_mock.assert_not_called()
84
+ self.assertEqual(result["source"], "cached_modal_example")
85
+
86
+ def test_empty_input_traces_sanitized_validation_failure(self) -> None:
87
+ with patch(
88
+ "app.queue_trace",
89
+ return_value=("trace-id", "queued"),
90
+ ) as queue_mock:
91
+ result = app.analyze_notice("")
92
+ self.assertFalse(result["ok"])
93
+ self.assertEqual(
94
+ queue_mock.call_args.kwargs["failure_category"],
95
+ "validation_empty",
96
+ )
97
+
98
+ def test_missing_credentials_traces_without_model_call(self) -> None:
99
+ with patch(
100
+ "app.model_status",
101
+ return_value={"connected": False, "label": "missing"},
102
+ ), patch("app.call_model") as model_mock, patch(
103
+ "app.queue_trace",
104
+ return_value=("trace-id", "queued"),
105
+ ) as queue_mock:
106
+ result = app.analyze_notice("test message")
107
+ self.assertFalse(result["ok"])
108
+ model_mock.assert_not_called()
109
+ self.assertEqual(
110
+ queue_mock.call_args.kwargs["failure_category"],
111
+ "credentials_missing",
112
+ )
113
+
114
+ def test_success_uses_existing_model_call_once(self) -> None:
115
+ assessment = {
116
+ "risk_label": "Verify first",
117
+ "simple_explanation": "Check independently.",
118
+ "red_flags": ["Unverified sender"],
119
+ "safe_next_steps": ["Use an official channel."],
120
+ "reply_draft": "Please confirm through your official channel.",
121
+ }
122
+
123
+ def fake_call(_text, _image, telemetry):
124
+ telemetry.update(
125
+ {
126
+ "modal_called": True,
127
+ "modal_ms": 120,
128
+ "retry_count": 1,
129
+ "parse_ms": 1,
130
+ "normalize_ms": 1,
131
+ }
132
+ )
133
+ return assessment
134
+
135
+ with patch(
136
+ "app.model_status",
137
+ return_value={"connected": True, "label": "ready"},
138
+ ), patch("app.call_model", side_effect=fake_call) as model_mock, patch(
139
+ "app.queue_trace",
140
+ return_value=("trace-id", "queued"),
141
+ ) as queue_mock:
142
+ result = app.analyze_notice("test message")
143
+ self.assertTrue(result["ok"])
144
+ model_mock.assert_called_once()
145
+ self.assertTrue(queue_mock.call_args.kwargs["modal_called"])
146
+ self.assertEqual(queue_mock.call_args.kwargs["retry_count"], 1)
147
+
148
+ def test_timeout_is_sanitized(self) -> None:
149
+ timeout = APITimeoutError(request=httpx.Request("POST", "https://example.invalid"))
150
+ with patch(
151
+ "app.model_status",
152
+ return_value={"connected": True, "label": "ready"},
153
+ ), patch("app.call_model", side_effect=timeout), patch(
154
+ "app.queue_trace",
155
+ return_value=("trace-id", "queued"),
156
+ ) as queue_mock:
157
+ result = app.analyze_notice("test message")
158
+ self.assertFalse(result["ok"])
159
+ self.assertEqual(queue_mock.call_args.kwargs["failure_category"], "timeout")
160
+
161
+ def test_http_failure_is_sanitized(self) -> None:
162
+ request = httpx.Request("POST", "https://example.invalid")
163
+ error = APIStatusError(
164
+ "service unavailable",
165
+ response=httpx.Response(503, request=request),
166
+ body={"private": "must not be traced"},
167
+ )
168
+ with patch(
169
+ "app.model_status",
170
+ return_value={"connected": True, "label": "ready"},
171
+ ), patch("app.call_model", side_effect=error), patch(
172
+ "app.queue_trace",
173
+ return_value=("trace-id", "queued"),
174
+ ) as queue_mock:
175
+ result = app.analyze_notice("test message")
176
+ self.assertFalse(result["ok"])
177
+ self.assertEqual(queue_mock.call_args.kwargs["failure_category"], "http_error")
178
+ self.assertNotIn("private", json.dumps(queue_mock.call_args.kwargs))
179
+
180
+ def test_malformed_output_is_sanitized(self) -> None:
181
+ with patch(
182
+ "app.model_status",
183
+ return_value={"connected": True, "label": "ready"},
184
+ ), patch("app.call_model", side_effect=ValueError("PRIVATE RAW OUTPUT")), patch(
185
+ "app.queue_trace",
186
+ return_value=("trace-id", "queued"),
187
+ ) as queue_mock:
188
+ result = app.analyze_notice("test message")
189
+ self.assertFalse(result["ok"])
190
+ self.assertEqual(
191
+ queue_mock.call_args.kwargs["failure_category"],
192
+ "invalid_model_output",
193
+ )
194
+ self.assertNotIn("PRIVATE RAW OUTPUT", json.dumps(queue_mock.call_args.kwargs))
195
+
196
+ def test_normalization_failure_uses_normalize_stage(self) -> None:
197
+ telemetry: dict = {}
198
+ with self.assertRaises(ValueError):
199
+ app.parse_model_json('{"risk_label":"invalid"}', telemetry)
200
+ self.assertTrue(telemetry["parse_completed"])
201
+ self.assertNotIn("normalize_completed", telemetry)
202
+
203
+ def test_model_retry_is_counted_without_extra_trace_call(self) -> None:
204
+ request = httpx.Request("POST", "https://example.invalid")
205
+ unavailable = APIStatusError(
206
+ "unavailable",
207
+ response=httpx.Response(503, request=request),
208
+ body=None,
209
+ )
210
+
211
+ class Completions:
212
+ def __init__(self):
213
+ self.calls = 0
214
+
215
+ def create(self, **_kwargs):
216
+ self.calls += 1
217
+ if self.calls == 1:
218
+ raise unavailable
219
+ message = type("Message", (), {"content": json.dumps({
220
+ "risk_label": "Verify first",
221
+ "simple_explanation": "Verify independently.",
222
+ "red_flags": ["Unverified sender"],
223
+ "safe_next_steps": ["Use an official channel."],
224
+ "reply_draft": "Please confirm through an official channel.",
225
+ })})()
226
+ choice = type("Choice", (), {"message": message})()
227
+ return type("Completion", (), {"choices": [choice]})()
228
+
229
+ completions = Completions()
230
+ client = type(
231
+ "Client",
232
+ (),
233
+ {"chat": type("Chat", (), {"completions": completions})()},
234
+ )()
235
+ telemetry: dict = {}
236
+ with patch("app.create_model_client", return_value=(client, "model")), patch.dict(
237
+ "os.environ",
238
+ {"MODEL_MAX_ATTEMPTS": "2", "MODEL_RETRY_DELAY_SECONDS": "0"},
239
+ ):
240
+ result = app.call_model("test", "", telemetry)
241
+ self.assertEqual(result["risk_label"], "Verify first")
242
+ self.assertEqual(completions.calls, 2)
243
+ self.assertEqual(telemetry["retry_count"], 1)
244
+
245
+ def test_publisher_persists_batch(self) -> None:
246
+ publisher = trace_runtime.TracePublisher()
247
+ with tempfile.TemporaryDirectory() as directory, patch.object(
248
+ trace_runtime,
249
+ "PENDING_DIR",
250
+ Path(directory),
251
+ ):
252
+ records = [self.sample_record() for _ in range(20)]
253
+ publisher._persist_batch(records)
254
+ paths = list(Path(directory).glob("*.jsonl"))
255
+ self.assertEqual(len(paths), 1)
256
+ self.assertEqual(len(paths[0].read_text().splitlines()), 20)
257
+
258
+ def test_concurrent_enqueue_and_queue_limit(self) -> None:
259
+ with patch.object(trace_runtime, "MAX_QUEUE_SIZE", 20):
260
+ publisher = trace_runtime.TracePublisher()
261
+ with patch.object(publisher, "_ensure_worker"):
262
+ threads = [
263
+ threading.Thread(target=publisher.enqueue, args=(self.sample_record(),))
264
+ for _ in range(20)
265
+ ]
266
+ for thread in threads:
267
+ thread.start()
268
+ for thread in threads:
269
+ thread.join()
270
+ self.assertEqual(publisher.status()["queued"], 20)
271
+ self.assertEqual(publisher.queue.qsize(), 20)
272
+ self.assertEqual(publisher.enqueue(self.sample_record()), "dropped")
273
+ self.assertEqual(publisher.status()["dropped"], 1)
274
+
275
+ def test_hub_failure_keeps_pending_shard(self) -> None:
276
+ publisher = trace_runtime.TracePublisher()
277
+ with tempfile.TemporaryDirectory() as directory, patch.object(
278
+ trace_runtime,
279
+ "PENDING_DIR",
280
+ Path(directory),
281
+ ), patch.dict("os.environ", {"HF_TOKEN": "test-token"}), patch(
282
+ "huggingface_hub.HfApi.upload_file",
283
+ side_effect=RuntimeError("offline"),
284
+ ), patch("trace_runtime.time.sleep"):
285
+ publisher._persist_batch([self.sample_record()])
286
+ publisher._upload_pending()
287
+ self.assertEqual(len(list(Path(directory).glob("*.jsonl"))), 1)
288
+ self.assertEqual(publisher.status()["upload_failures"], 1)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ unittest.main()
trace_runtime.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fast, deterministic, privacy-safe pipeline tracing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import queue
8
+ import re
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ TRACE_ROOT = Path(os.getenv("TRACE_DIR", ROOT / "traces"))
18
+ PENDING_DIR = TRACE_ROOT / "pending"
19
+ DATASET_REPO = os.getenv(
20
+ "HF_TRACE_DATASET_REPO",
21
+ "build-small-hackathon/pakistan-notice-helper-traces",
22
+ )
23
+ SCHEMA_VERSION = "1.0"
24
+ BATCH_SIZE = max(1, int(os.getenv("TRACE_BATCH_SIZE", "20")))
25
+ FLUSH_SECONDS = max(1.0, float(os.getenv("TRACE_FLUSH_SECONDS", "60")))
26
+ MAX_QUEUE_SIZE = max(1, int(os.getenv("TRACE_MAX_QUEUE_SIZE", "5000")))
27
+
28
+ PIPELINE_STEPS = (
29
+ "receive",
30
+ "validate",
31
+ "cache_lookup",
32
+ "modal_request",
33
+ "parse_json",
34
+ "normalize_result",
35
+ "reply_filter",
36
+ "response",
37
+ )
38
+ RISK_LABELS = {
39
+ "Looks normal",
40
+ "Verify first",
41
+ "Suspicious",
42
+ "Likely scam",
43
+ "Inappropriate",
44
+ "none",
45
+ }
46
+ FAILURE_CATEGORIES = {
47
+ "none",
48
+ "validation_empty",
49
+ "credentials_missing",
50
+ "http_auth",
51
+ "http_error",
52
+ "connection_error",
53
+ "timeout",
54
+ "invalid_model_output",
55
+ "internal_error",
56
+ }
57
+ PIPELINE_STATUSES = {"completed", "skipped", "rejected", "failed", "hit", "miss"}
58
+ REQUEST_SOURCES = {"user", "cached_modal_example"}
59
+ SIGNAL_PATTERNS = {
60
+ "otp": r"\b(?:otp|one[- ]time (?:pin|password)|verification code)\b",
61
+ "cnic": r"\bcnic\b",
62
+ "credentials": r"\b(?:pin|password|cvv|card details?|bank details?)\b",
63
+ "link": r"(?:https?://|www\.|bit\.ly|tinyurl\.|cutt\.ly|\.xyz\b|\.top\b)",
64
+ "urgency": r"\b(?:urgent|immediately|today|now|within \d+|last warning)\b",
65
+ "payment": r"\b(?:pay|payment|fee|fine|transfer|send money|rs\.?|pkr)\b",
66
+ "refund_or_prize": r"\b(?:refund|prize|winner|lottery|cashback|reward)\b",
67
+ "courier": r"\b(?:parcel|courier|delivery|pakistan post|leopards|tcs|customs)\b",
68
+ "challan": r"\b(?:challan|traffic fine|traffic violation|e-challan)\b",
69
+ "account_threat": (
70
+ r"\b(?:account|sim|service|electricity)\b.{0,50}"
71
+ r"\b(?:block|blocked|suspend|closed|disconnect)\b"
72
+ ),
73
+ }
74
+ EXAMPLE_PROFILES = {
75
+ "text-courier": ("text", "courier", {"link", "urgency", "payment", "courier"}),
76
+ "text-fbr": ("text", "fbr", {"cnic", "credentials", "urgency", "refund_or_prize"}),
77
+ "text-bank": ("text", "bank", {"otp", "urgency", "account_threat"}),
78
+ "image-courier": ("image", "courier", {"link", "urgency", "courier"}),
79
+ "image-mobile": ("image", "marketplace", {"credentials"}),
80
+ "image-traffic": ("image", "traffic_challan", {"link", "urgency", "payment", "challan"}),
81
+ }
82
+
83
+
84
+ def _bucket_number(value: float, thresholds: tuple[tuple[float, str], ...]) -> str:
85
+ for maximum, label in thresholds:
86
+ if value <= maximum:
87
+ return label
88
+ return thresholds[-1][1]
89
+
90
+
91
+ def duration_bucket(milliseconds: float) -> str:
92
+ return _bucket_number(
93
+ max(0.0, milliseconds),
94
+ (
95
+ (1, "0-1ms"),
96
+ (5, "2-5ms"),
97
+ (10, "6-10ms"),
98
+ (50, "11-50ms"),
99
+ (250, "51-250ms"),
100
+ (1000, "251-1000ms"),
101
+ (5000, "1-5s"),
102
+ (30000, "5-30s"),
103
+ (float("inf"), "30s+"),
104
+ ),
105
+ )
106
+
107
+
108
+ def input_size_bucket(length: int) -> str:
109
+ return _bucket_number(
110
+ max(0, length),
111
+ (
112
+ (0, "empty"),
113
+ (160, "1-160"),
114
+ (500, "161-500"),
115
+ (2000, "501-2000"),
116
+ (6000, "2001-6000"),
117
+ (12000, "6001-12000"),
118
+ (float("inf"), "12000+"),
119
+ ),
120
+ )
121
+
122
+
123
+ def image_size_bucket(data_url_length: int) -> str:
124
+ estimated_bytes = max(0, int(data_url_length * 0.75))
125
+ return _bucket_number(
126
+ estimated_bytes,
127
+ (
128
+ (0, "none"),
129
+ (100_000, "up-to-100KB"),
130
+ (500_000, "100-500KB"),
131
+ (2_000_000, "500KB-2MB"),
132
+ (8_000_000, "2-8MB"),
133
+ (float("inf"), "8MB+"),
134
+ ),
135
+ )
136
+
137
+
138
+ def detect_signals(text: str, example_id: str = "") -> dict[str, bool]:
139
+ detected = {
140
+ name: bool(re.search(pattern, text or "", re.I | re.S))
141
+ for name, pattern in SIGNAL_PATTERNS.items()
142
+ }
143
+ profile = EXAMPLE_PROFILES.get(example_id)
144
+ if profile:
145
+ for name in profile[2]:
146
+ detected[name] = True
147
+ return detected
148
+
149
+
150
+ def detect_category(text: str, signals: dict[str, bool], example_id: str = "") -> str:
151
+ profile = EXAMPLE_PROFILES.get(example_id)
152
+ if profile:
153
+ return profile[1]
154
+ lowered = (text or "").lower()
155
+ categories = (
156
+ ("fbr", ("fbr", "taxpayer", "tax refund")),
157
+ ("bank", ("bank", "hbl", "ubl", "meezan", "alfalah")),
158
+ ("wallet", ("easypaisa", "jazzcash", "wallet")),
159
+ ("utility", ("electricity", "gas bill", "utility", "lesco", "k-electric")),
160
+ ("traffic_challan", ("challan", "traffic fine", "traffic violation")),
161
+ ("courier", ("parcel", "courier", "delivery", "pakistan post", "leopards", "tcs")),
162
+ ("customs", ("customs", "duty")),
163
+ ("university", ("university", "admission", "scholarship", "hec")),
164
+ ("job", ("job", "salary", "recruiter", "employment")),
165
+ ("marketplace", ("buyer", "seller", "marketplace", "whatsapp")),
166
+ )
167
+ for category, terms in categories:
168
+ if any(term in lowered for term in terms):
169
+ return category
170
+ if signals["challan"]:
171
+ return "traffic_challan"
172
+ if signals["courier"]:
173
+ return "courier"
174
+ return "unknown"
175
+
176
+
177
+ def detect_language_hint(text: str) -> str:
178
+ has_urdu = bool(re.search(r"[\u0600-\u06ff]", text or ""))
179
+ has_latin = bool(re.search(r"[A-Za-z]", text or ""))
180
+ roman_terms = bool(
181
+ re.search(
182
+ r"\b(?:aap|apka|apki|hai|hain|karo|karein|paisa|rupay|bhej|jaldi)\b",
183
+ text or "",
184
+ re.I,
185
+ )
186
+ )
187
+ if has_urdu and has_latin:
188
+ return "mixed_urdu_latin"
189
+ if has_urdu:
190
+ return "urdu_script"
191
+ if roman_terms:
192
+ return "roman_urdu"
193
+ if has_latin:
194
+ return "latin_script"
195
+ return "unknown"
196
+
197
+
198
+ def safe_summary(category: str, signals: dict[str, bool], input_type: str) -> str:
199
+ category_labels = {
200
+ "fbr": "FBR-style",
201
+ "bank": "Bank-style",
202
+ "wallet": "Wallet-style",
203
+ "utility": "Utility-style",
204
+ "traffic_challan": "Traffic-challan-style",
205
+ "courier": "Courier-style",
206
+ "customs": "Customs-style",
207
+ "university": "Education-style",
208
+ "job": "Job-style",
209
+ "marketplace": "Marketplace-style",
210
+ "unknown": "Unclassified",
211
+ }
212
+ signal_labels = {
213
+ "otp": "OTP",
214
+ "cnic": "CNIC",
215
+ "credentials": "credential",
216
+ "link": "link",
217
+ "urgency": "urgency",
218
+ "payment": "payment",
219
+ "refund_or_prize": "refund-or-prize",
220
+ "courier": "courier",
221
+ "challan": "challan",
222
+ "account_threat": "account-threat",
223
+ }
224
+ active = [signal_labels[name] for name, enabled in signals.items() if enabled][:4]
225
+ suffix = f" with {', '.join(active)} signals" if active else " with no mapped signals"
226
+ return f"{category_labels[category]} {input_type} input{suffix}"
227
+
228
+
229
+ def build_input_profile(text: str, image_data_url: str, example_id: str = "") -> dict[str, Any]:
230
+ profile = EXAMPLE_PROFILES.get(example_id)
231
+ if profile:
232
+ input_type = profile[0]
233
+ elif image_data_url and text:
234
+ input_type = "text_and_image"
235
+ elif image_data_url:
236
+ input_type = "image"
237
+ else:
238
+ input_type = "text"
239
+ signals = detect_signals(text, example_id)
240
+ category = detect_category(text, signals, example_id)
241
+ return {
242
+ "type": input_type,
243
+ "text_character_bucket": input_size_bucket(len(text or "")),
244
+ "text_byte_bucket": input_size_bucket(len((text or "").encode("utf-8"))),
245
+ "image_size_bucket": image_size_bucket(len(image_data_url or "")),
246
+ "category": category,
247
+ "language_hint": detect_language_hint(text),
248
+ "signals": signals,
249
+ "safe_summary": safe_summary(category, signals, input_type),
250
+ }
251
+
252
+
253
+ def build_trace_record(
254
+ *,
255
+ text: str,
256
+ image_data_url: str,
257
+ example_id: str,
258
+ request_source: str,
259
+ pipeline_status: dict[str, str],
260
+ pipeline_ms: dict[str, float],
261
+ modal_called: bool,
262
+ modal_ms: float,
263
+ retry_count: int,
264
+ assessment: dict[str, Any] | None,
265
+ failure_category: str = "none",
266
+ failure_stage: str = "none",
267
+ ) -> dict[str, Any]:
268
+ trace_id = str(uuid.uuid4())
269
+ risk_label = str((assessment or {}).get("risk_label", "none"))
270
+ if risk_label not in RISK_LABELS:
271
+ risk_label = "none"
272
+ commit = (
273
+ os.getenv("SPACE_COMMIT")
274
+ or os.getenv("GIT_COMMIT")
275
+ or os.getenv("COMMIT_SHA")
276
+ or ""
277
+ )
278
+ commit = commit[:64] if re.fullmatch(r"[0-9a-fA-F]{7,64}", commit) else "unknown"
279
+ request_source = (
280
+ request_source if request_source in REQUEST_SOURCES else "user"
281
+ )
282
+ return {
283
+ "schema_version": SCHEMA_VERSION,
284
+ "trace_id": trace_id,
285
+ "timestamp": datetime.now(timezone.utc).isoformat(),
286
+ "app_commit": commit,
287
+ "request_source": request_source,
288
+ "input": build_input_profile(text, image_data_url, example_id),
289
+ "pipeline_steps": [
290
+ {
291
+ "step": step,
292
+ "status": (
293
+ pipeline_status.get(step, "skipped")
294
+ if pipeline_status.get(step, "skipped") in PIPELINE_STATUSES
295
+ else "skipped"
296
+ ),
297
+ "duration_bucket": duration_bucket(pipeline_ms.get(step, 0.0)),
298
+ }
299
+ for step in PIPELINE_STEPS
300
+ ],
301
+ "cache": {
302
+ "hit": request_source == "cached_modal_example",
303
+ "example_id": example_id if example_id in EXAMPLE_PROFILES else "none",
304
+ },
305
+ "modal": {
306
+ "called": bool(modal_called),
307
+ "model_family": (
308
+ "qwen3.6-27b-mtp"
309
+ if "qwen3.6-27b-mtp"
310
+ in os.getenv("MODEL_NAME", "qwen3.6-27b-mtp").lower()
311
+ else "other"
312
+ ),
313
+ "latency_bucket": duration_bucket(modal_ms),
314
+ "retry_count": max(0, min(int(retry_count), 20)),
315
+ "outcome": (
316
+ "success"
317
+ if modal_called and failure_category == "none"
318
+ else "failed"
319
+ if modal_called
320
+ else "not_called"
321
+ ),
322
+ },
323
+ "result": {
324
+ "risk_label": risk_label,
325
+ "red_flag_count": min(len((assessment or {}).get("red_flags", [])), 50),
326
+ "safe_next_step_count": min(
327
+ len((assessment or {}).get("safe_next_steps", [])),
328
+ 50,
329
+ ),
330
+ "reply_draft_returned": bool((assessment or {}).get("reply_draft")),
331
+ "reply_draft_policy": (
332
+ "allowed"
333
+ if risk_label in {"Verify first", "Suspicious"}
334
+ else "suppressed"
335
+ if risk_label != "none"
336
+ else "not_applicable"
337
+ ),
338
+ },
339
+ "failure": {
340
+ "category": (
341
+ failure_category
342
+ if failure_category in FAILURE_CATEGORIES
343
+ else "internal_error"
344
+ ),
345
+ "stage": failure_stage if failure_stage in {*PIPELINE_STEPS, "none"} else "response",
346
+ },
347
+ "privacy": {
348
+ "raw_input_stored": False,
349
+ "raw_image_stored": False,
350
+ "raw_model_output_stored": False,
351
+ "exception_text_stored": False,
352
+ "identifiers_stored": False,
353
+ },
354
+ }
355
+
356
+
357
+ def validate_trace(record: Any) -> list[str]:
358
+ errors: list[str] = []
359
+ if not isinstance(record, dict):
360
+ return ["Trace must be an object."]
361
+ required = {
362
+ "schema_version",
363
+ "trace_id",
364
+ "timestamp",
365
+ "app_commit",
366
+ "request_source",
367
+ "input",
368
+ "pipeline_steps",
369
+ "cache",
370
+ "modal",
371
+ "result",
372
+ "failure",
373
+ "privacy",
374
+ }
375
+ missing = required - record.keys()
376
+ if missing:
377
+ errors.append("Missing fields: " + ", ".join(sorted(missing)))
378
+ if record.get("schema_version") != SCHEMA_VERSION:
379
+ errors.append("Unsupported schema_version.")
380
+ if record.get("result", {}).get("risk_label") not in RISK_LABELS:
381
+ errors.append("Invalid risk label.")
382
+ privacy = record.get("privacy", {})
383
+ if not isinstance(privacy, dict) or any(privacy.get(key) is not False for key in privacy):
384
+ errors.append("Privacy flags must all be false.")
385
+ steps = record.get("pipeline_steps", [])
386
+ if not isinstance(steps, list) or [item.get("step") for item in steps] != list(
387
+ PIPELINE_STEPS
388
+ ):
389
+ errors.append("Pipeline steps are invalid or out of order.")
390
+ forbidden_keys = {
391
+ "raw_input",
392
+ "raw_text",
393
+ "image_data_url",
394
+ "raw_model_output",
395
+ "reply_draft",
396
+ "simple_explanation",
397
+ "error",
398
+ "exception",
399
+ "url",
400
+ "phone",
401
+ "account_number",
402
+ }
403
+
404
+ def walk(value: Any) -> None:
405
+ if isinstance(value, dict):
406
+ for key, child in value.items():
407
+ if key.lower() in forbidden_keys:
408
+ errors.append(f"Forbidden field: {key}")
409
+ walk(child)
410
+ elif isinstance(value, list):
411
+ for child in value:
412
+ walk(child)
413
+
414
+ walk(record)
415
+ return sorted(set(errors))
416
+
417
+
418
+ class TracePublisher:
419
+ def __init__(self) -> None:
420
+ self.queue: queue.Queue[dict[str, Any]] = queue.Queue(MAX_QUEUE_SIZE)
421
+ self.lock = threading.Lock()
422
+ self.thread: threading.Thread | None = None
423
+ self.counters = {
424
+ "queued": 0,
425
+ "persisted": 0,
426
+ "uploaded": 0,
427
+ "upload_failures": 0,
428
+ "dropped": 0,
429
+ }
430
+
431
+ def enqueue(self, record: dict[str, Any]) -> str:
432
+ if validate_trace(record):
433
+ with self.lock:
434
+ self.counters["dropped"] += 1
435
+ return "invalid"
436
+ try:
437
+ self.queue.put_nowait(record)
438
+ except queue.Full:
439
+ with self.lock:
440
+ self.counters["dropped"] += 1
441
+ return "dropped"
442
+ with self.lock:
443
+ self.counters["queued"] += 1
444
+ self._ensure_worker()
445
+ return "queued"
446
+
447
+ def status(self) -> dict[str, Any]:
448
+ with self.lock:
449
+ counters = dict(self.counters)
450
+ counters.update(
451
+ {
452
+ "queue_size": self.queue.qsize(),
453
+ "pending_shards": len(list(PENDING_DIR.glob("*.jsonl")))
454
+ if PENDING_DIR.exists()
455
+ else 0,
456
+ "dataset_repo": DATASET_REPO,
457
+ }
458
+ )
459
+ return counters
460
+
461
+ def _ensure_worker(self) -> None:
462
+ with self.lock:
463
+ if self.thread and self.thread.is_alive():
464
+ return
465
+ self.thread = threading.Thread(
466
+ target=self._worker,
467
+ name="privacy-safe-trace-publisher",
468
+ daemon=True,
469
+ )
470
+ self.thread.start()
471
+
472
+ def _worker(self) -> None:
473
+ batch: list[dict[str, Any]] = []
474
+ deadline: float | None = None
475
+ self._upload_pending()
476
+ while True:
477
+ timeout = (
478
+ max(0.05, deadline - time.monotonic())
479
+ if deadline is not None
480
+ else FLUSH_SECONDS
481
+ )
482
+ try:
483
+ record = self.queue.get(timeout=timeout)
484
+ batch.append(record)
485
+ if deadline is None:
486
+ deadline = time.monotonic() + FLUSH_SECONDS
487
+ except queue.Empty:
488
+ pass
489
+ if batch and (
490
+ len(batch) >= BATCH_SIZE
491
+ or (deadline is not None and time.monotonic() >= deadline)
492
+ ):
493
+ self._persist_batch(batch)
494
+ batch = []
495
+ deadline = None
496
+ self._upload_pending()
497
+
498
+ def _persist_batch(self, records: list[dict[str, Any]]) -> None:
499
+ PENDING_DIR.mkdir(parents=True, exist_ok=True)
500
+ pending_count = self._pending_record_count()
501
+ capacity = max(0, MAX_QUEUE_SIZE - pending_count)
502
+ if capacity == 0:
503
+ with self.lock:
504
+ self.counters["dropped"] += len(records)
505
+ return
506
+ accepted = records[:capacity]
507
+ dropped = len(records) - len(accepted)
508
+ if dropped:
509
+ with self.lock:
510
+ self.counters["dropped"] += dropped
511
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
512
+ filename = f"trace-{timestamp}-{uuid.uuid4().hex[:8]}.jsonl"
513
+ final_path = PENDING_DIR / filename
514
+ temporary_path = final_path.with_suffix(".tmp")
515
+ content = "".join(
516
+ json.dumps(record, sort_keys=True, ensure_ascii=True) + "\n"
517
+ for record in accepted
518
+ )
519
+ temporary_path.write_text(content, encoding="utf-8")
520
+ os.replace(temporary_path, final_path)
521
+ with self.lock:
522
+ self.counters["persisted"] += len(accepted)
523
+
524
+ def _pending_record_count(self) -> int:
525
+ if not PENDING_DIR.exists():
526
+ return 0
527
+ count = 0
528
+ for path in PENDING_DIR.glob("*.jsonl"):
529
+ try:
530
+ count += sum(
531
+ 1
532
+ for line in path.read_text(encoding="utf-8").splitlines()
533
+ if line
534
+ )
535
+ except OSError:
536
+ continue
537
+ return count
538
+
539
+ def _upload_pending(self) -> None:
540
+ token = os.getenv("HF_TOKEN", "").strip()
541
+ if not token or not PENDING_DIR.exists():
542
+ return
543
+ try:
544
+ from huggingface_hub import HfApi
545
+
546
+ api = HfApi(token=token)
547
+ for path in sorted(PENDING_DIR.glob("*.jsonl")):
548
+ date_path = datetime.now(timezone.utc).strftime("%Y/%m/%d")
549
+ uploaded = False
550
+ for attempt in range(3):
551
+ try:
552
+ api.upload_file(
553
+ path_or_fileobj=str(path),
554
+ path_in_repo=f"data/{date_path}/{path.name}",
555
+ repo_id=DATASET_REPO,
556
+ repo_type="dataset",
557
+ commit_message=f"Add privacy-safe trace shard {path.name}",
558
+ )
559
+ uploaded = True
560
+ break
561
+ except Exception:
562
+ if attempt < 2:
563
+ time.sleep(2**attempt)
564
+ if not uploaded:
565
+ with self.lock:
566
+ self.counters["upload_failures"] += 1
567
+ return
568
+ count = sum(1 for line in path.read_text(encoding="utf-8").splitlines() if line)
569
+ path.unlink(missing_ok=True)
570
+ with self.lock:
571
+ self.counters["uploaded"] += count
572
+ except Exception:
573
+ with self.lock:
574
+ self.counters["upload_failures"] += 1
575
+
576
+
577
+ PUBLISHER = TracePublisher()
578
+
579
+
580
+ def start_trace_worker() -> None:
581
+ PUBLISHER._ensure_worker()
582
+
583
+
584
+ def queue_trace(**kwargs: Any) -> tuple[str, str]:
585
+ record = build_trace_record(**kwargs)
586
+ return record["trace_id"], PUBLISHER.enqueue(record)
587
+
588
+
589
+ def trace_status() -> dict[str, Any]:
590
+ return PUBLISHER.status()