kingabzpro Codex commited on
Commit
e4f211a
·
1 Parent(s): 0661c20

Improve privacy-safe trace quality

Browse files

Strengthen schema validation, add live dataset auditing, improve image category and tactic mapping, and distinguish failed image assessments from genuinely unclassified content.

Co-authored-by: Codex <codex@openai.com>

README.md CHANGED
@@ -159,6 +159,12 @@ They do **not** include raw notice text, OCR text, screenshots, names, phone
159
  numbers, credentials, or full model responses. See
160
  the trace [`dataset card`](traces/dataset_card.md).
161
 
 
 
 
 
 
 
162
  ## Privacy and Limitations
163
 
164
  - Inputs are processed in memory and are not written to disk by the app.
 
159
  numbers, credentials, or full model responses. See
160
  the trace [`dataset card`](traces/dataset_card.md).
161
 
162
+ Audit the live trace dataset with:
163
+
164
+ ```bash
165
+ python -m traces.scripts.analyze_trace_dataset
166
+ ```
167
+
168
  ## Privacy and Limitations
169
 
170
  - Inputs are processed in memory and are not written to disk by the app.
app.py CHANGED
@@ -37,6 +37,33 @@ REQUIRED_FIELDS = {
37
  "safe_next_steps",
38
  "reply_draft",
39
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  EXAMPLE_CACHE_PATH = ROOT / "data" / "example_assessments.json"
41
 
42
  SYSTEM_PROMPT = """Assess Pakistani notices and messages for scam risk.
@@ -67,6 +94,11 @@ Evidence rules:
67
  relevant notice; do not call it irrelevant merely because it looks harmless.
68
 
69
  Output rules:
 
 
 
 
 
70
  - explanation: 1-3 short sentences naming the decisive visible evidence.
71
  - red_flags: 1-4 concise evidence-based items. For a normal relevant notice,
72
  use one item such as "No clear scam indicators in the supplied message."
@@ -109,8 +141,14 @@ OUTPUT_SCHEMA: dict[str, Any] = {
109
  "red_flags": {"type": "array", "items": {"type": "string"}},
110
  "safe_next_steps": {"type": "array", "items": {"type": "string"}},
111
  "reply_draft": {"type": "string"},
 
 
 
 
 
 
112
  },
113
- "required": sorted(REQUIRED_FIELDS),
114
  "additionalProperties": False,
115
  }
116
 
@@ -173,6 +211,15 @@ def normalize_assessment(value: Any) -> dict[str, Any]:
173
  else ""
174
  ),
175
  }
 
 
 
 
 
 
 
 
 
176
  for field in ("simple_explanation",):
177
  if not result[field]:
178
  raise ValueError(f"{field} must not be empty.")
 
37
  "safe_next_steps",
38
  "reply_draft",
39
  }
40
+ TRACE_CATEGORIES = (
41
+ "fbr",
42
+ "bank",
43
+ "wallet",
44
+ "utility",
45
+ "traffic_challan",
46
+ "courier",
47
+ "customs",
48
+ "university",
49
+ "job",
50
+ "marketplace",
51
+ "unknown",
52
+ )
53
+ TRACE_TACTICS = (
54
+ "otp",
55
+ "cnic",
56
+ "credentials",
57
+ "link",
58
+ "urgency",
59
+ "payment",
60
+ "refund_or_prize",
61
+ "courier",
62
+ "challan",
63
+ "account_threat",
64
+ "off_platform_contact",
65
+ "impersonation",
66
+ )
67
  EXAMPLE_CACHE_PATH = ROOT / "data" / "example_assessments.json"
68
 
69
  SYSTEM_PROMPT = """Assess Pakistani notices and messages for scam risk.
 
94
  relevant notice; do not call it irrelevant merely because it looks harmless.
95
 
96
  Output rules:
97
+ - trace_category: choose exactly one privacy-safe category from the schema based
98
+ on the visible content. Use unknown only when no listed category applies.
99
+ - trace_tactics: choose every visible tactic from the schema. Use an empty
100
+ array when none applies. These values are metadata and must not contain raw
101
+ text, names, numbers, URLs, or explanations.
102
  - explanation: 1-3 short sentences naming the decisive visible evidence.
103
  - red_flags: 1-4 concise evidence-based items. For a normal relevant notice,
104
  use one item such as "No clear scam indicators in the supplied message."
 
141
  "red_flags": {"type": "array", "items": {"type": "string"}},
142
  "safe_next_steps": {"type": "array", "items": {"type": "string"}},
143
  "reply_draft": {"type": "string"},
144
+ "trace_category": {"type": "string", "enum": list(TRACE_CATEGORIES)},
145
+ "trace_tactics": {
146
+ "type": "array",
147
+ "items": {"type": "string", "enum": list(TRACE_TACTICS)},
148
+ "uniqueItems": True,
149
+ },
150
  },
151
+ "required": sorted(REQUIRED_FIELDS | {"trace_category", "trace_tactics"}),
152
  "additionalProperties": False,
153
  }
154
 
 
211
  else ""
212
  ),
213
  }
214
+ trace_category = value.get("trace_category")
215
+ trace_tactics = value.get("trace_tactics")
216
+ if trace_category in TRACE_CATEGORIES:
217
+ result["trace_category"] = trace_category
218
+ if isinstance(trace_tactics, list):
219
+ normalized_tactics = list(dict.fromkeys(
220
+ str(item) for item in trace_tactics if str(item) in TRACE_TACTICS
221
+ ))
222
+ result["trace_tactics"] = normalized_tactics
223
  for field in ("simple_explanation",):
224
  if not result[field]:
225
  raise ValueError(f"{field} must not be empty.")
tests/test_tracing.py CHANGED
@@ -15,6 +15,7 @@ from openai import APIStatusError, APITimeoutError
15
 
16
  import app
17
  from traces import runtime as trace_runtime
 
18
 
19
 
20
  class TraceTests(unittest.TestCase):
@@ -62,6 +63,40 @@ class TraceTests(unittest.TestCase):
62
  self.assertLess(elapsed_ms, 10)
63
  self.assertFalse(trace_runtime.validate_trace(records[0]))
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  def test_trace_uses_simplified_columns(self) -> None:
66
  image_record = self.sample_record()
67
  self.assertTrue(image_record["input"].startswith("image: "))
@@ -160,6 +195,51 @@ class TraceTests(unittest.TestCase):
160
  self.assertNotIn(assessment["red_flags"][0], serialized)
161
  self.assertNotIn("PRIVATE_IMAGE_BYTES", serialized)
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def test_urdu_image_assessment_maps_to_traffic_challan(self) -> None:
164
  record = trace_runtime.build_trace_record(
165
  text="",
 
15
 
16
  import app
17
  from traces import runtime as trace_runtime
18
+ from traces.scripts.validate_traces import validate_file
19
 
20
 
21
  class TraceTests(unittest.TestCase):
 
63
  self.assertLess(elapsed_ms, 10)
64
  self.assertFalse(trace_runtime.validate_trace(records[0]))
65
 
66
+ def test_trace_validation_rejects_extra_or_sensitive_columns(self) -> None:
67
+ record = self.sample_record()
68
+ record["notes"] = "PRIVATE RAW MESSAGE"
69
+ self.assertIn(
70
+ "Unexpected fields: notes",
71
+ trace_runtime.validate_trace(record),
72
+ )
73
+
74
+ record = self.sample_record()
75
+ record["input"] = "text: Visit https://private.example immediately"
76
+ self.assertIn(
77
+ "Input contains an unredacted URL.",
78
+ trace_runtime.validate_trace(record),
79
+ )
80
+
81
+ record = self.sample_record()
82
+ record["result_summary"] = "PRIVATE MODEL OUTPUT"
83
+ self.assertIn(
84
+ "Result summary does not match the deterministic fields.",
85
+ trace_runtime.validate_trace(record),
86
+ )
87
+
88
+ def test_file_validation_rejects_duplicate_trace_ids(self) -> None:
89
+ record = self.sample_record()
90
+ with tempfile.TemporaryDirectory() as directory:
91
+ path = Path(directory) / "duplicates.jsonl"
92
+ path.write_text(
93
+ json.dumps(record) + "\n" + json.dumps(record) + "\n",
94
+ encoding="utf-8",
95
+ )
96
+ count, errors = validate_file(path)
97
+ self.assertEqual(count, 2)
98
+ self.assertTrue(any("Duplicate trace ID" in error for error in errors))
99
+
100
  def test_trace_uses_simplified_columns(self) -> None:
101
  image_record = self.sample_record()
102
  self.assertTrue(image_record["input"].startswith("image: "))
 
195
  self.assertNotIn(assessment["red_flags"][0], serialized)
196
  self.assertNotIn("PRIVATE_IMAGE_BYTES", serialized)
197
 
198
+ def test_image_trace_rejects_unsupported_structured_model_metadata(self) -> None:
199
+ record = trace_runtime.build_trace_record(
200
+ text="",
201
+ image_data_url="data:image/png;base64,PRIVATE_IMAGE_BYTES",
202
+ example_id="",
203
+ assessment={
204
+ "risk_label": "Suspicious",
205
+ "simple_explanation": (
206
+ "The buyer asks to move the marketplace conversation "
207
+ "to WhatsApp."
208
+ ),
209
+ "red_flags": ["The sender identity is unverified."],
210
+ "safe_next_steps": ["Verify independently."],
211
+ "reply_draft": "",
212
+ "trace_category": "university",
213
+ "trace_tactics": ["refund_or_prize", "urgency"],
214
+ },
215
+ )
216
+
217
+ self.assertEqual(record["input_category"], "marketplace")
218
+ self.assertEqual(
219
+ record["scam_tactics"],
220
+ "off_platform_contact, impersonation",
221
+ )
222
+ self.assertEqual(
223
+ record["input"],
224
+ (
225
+ "image: Marketplace-style content with off-platform-contact, "
226
+ "impersonation signals"
227
+ ),
228
+ )
229
+ self.assertFalse(trace_runtime.validate_trace(record))
230
+
231
+ def test_failed_image_trace_is_assessment_unavailable(self) -> None:
232
+ record = trace_runtime.build_trace_record(
233
+ text="",
234
+ image_data_url="data:image/png;base64,PRIVATE_IMAGE_BYTES",
235
+ example_id="",
236
+ assessment=None,
237
+ )
238
+
239
+ self.assertEqual(record["input"], "image: Assessment unavailable")
240
+ self.assertEqual(record["risk_label"], "none")
241
+ self.assertFalse(trace_runtime.validate_trace(record))
242
+
243
  def test_urdu_image_assessment_maps_to_traffic_challan(self) -> None:
244
  record = trace_runtime.build_trace_record(
245
  text="",
traces/dataset_card.md CHANGED
@@ -32,7 +32,10 @@ a trace never makes an additional AI model call. Traces only observe the
32
  existing request path and convert it into allow-listed categories, booleans,
33
  and fixed descriptions. For image submissions, the existing assessment's
34
  explanation and red flags may be inspected transiently for this mapping, but
35
- their text is not stored.
 
 
 
36
 
37
  ## Fields
38
 
@@ -77,15 +80,38 @@ Images store only fixed descriptions. Screenshots, OCR text, model explanations,
77
  and model red flags are not stored. Users see a checked trace disclosure in the
78
  app and may opt out before each request.
79
 
 
 
 
 
80
  ## Provenance
81
 
82
  Seed traces represent the six public examples bundled with Pakistan Notice
83
  Helper. Runtime traces may represent successful, rejected, or failed requests.
84
  Trace generation itself does not invoke the model.
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  ## Limitations
87
 
88
  - Regex signals and category detection are approximate.
 
 
89
  - Regex redaction may miss unusual personal or confidential information.
90
  - Novelty is not researched against external threat-intelligence sources.
91
  - The dataset cannot reproduce original messages or screenshots.
 
32
  existing request path and convert it into allow-listed categories, booleans,
33
  and fixed descriptions. For image submissions, the existing assessment's
34
  explanation and red flags may be inspected transiently for this mapping, but
35
+ their text is not stored. New model responses also include enum-only category
36
+ and tactic hints. These hints are treated as untrusted: the trace mapper keeps
37
+ only values supported by the explanation or red flags and falls back to its
38
+ deterministic English/Urdu evidence rules when they disagree.
39
 
40
  ## Fields
41
 
 
80
  and model red flags are not stored. Users see a checked trace disclosure in the
81
  app and may opt out before each request.
82
 
83
+ When image analysis fails before an assessment is available, new records use
84
+ `image: Assessment unavailable`. This keeps service failures separate from
85
+ successful assessments whose content is genuinely unclassified.
86
+
87
  ## Provenance
88
 
89
  Seed traces represent the six public examples bundled with Pakistan Notice
90
  Helper. Runtime traces may represent successful, rejected, or failed requests.
91
  Trace generation itself does not invoke the model.
92
 
93
+ The seed rows are illustrative examples, not an evaluation split. All six
94
+ currently have the `Likely scam` label, so they must not be used to estimate
95
+ class balance, accuracy, recall, or real-world scam prevalence.
96
+
97
+ Runtime rows are an operational log and intentionally preserve repeated
98
+ requests. Consequently, repeated examples, unclassified image descriptions,
99
+ and incomplete `none` assessments may be common. For training or evaluation,
100
+ create a separate curated split that:
101
+
102
+ - excludes `risk_label: none`
103
+ - reviews or excludes unclassified image rows
104
+ - deduplicates on the privacy-safe `input` and result columns
105
+ - uses a task-appropriate class-balancing strategy
106
+
107
+ The source repository includes `traces/scripts/analyze_trace_dataset.py` for
108
+ schema validation and a reproducible summary of these quality indicators.
109
+
110
  ## Limitations
111
 
112
  - Regex signals and category detection are approximate.
113
+ - Runtime frequencies may reflect testing or repeated usage, not population
114
+ prevalence.
115
  - Regex redaction may miss unusual personal or confidential information.
116
  - Novelty is not researched against external threat-intelligence sources.
117
  - The dataset cannot reproduce original messages or screenshots.
traces/runtime.py CHANGED
@@ -67,6 +67,16 @@ SIGNAL_PATTERNS = {
67
  r"\b(?:account|sim|service|electricity)\b.{0,50}"
68
  r"\b(?:block|blocked|suspend|closed|disconnect)\b"
69
  ),
 
 
 
 
 
 
 
 
 
 
70
  }
71
  EXAMPLE_PROFILES = {
72
  "text-courier": ("text", "courier", {"link", "urgency", "payment", "courier"}),
@@ -114,6 +124,141 @@ SENSITIVE_VALUE_PATTERN = re.compile(
114
  re.I,
115
  )
116
  TITLE_CASE_PATTERN = re.compile(r"\b[A-Z][a-z]{2,}\b")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
 
119
  def detect_signals(text: str, example_id: str = "") -> dict[str, bool]:
@@ -135,45 +280,7 @@ def detect_category(text: str, signals: dict[str, bool], example_id: str = "") -
135
  if signals["challan"]:
136
  return "traffic_challan"
137
  lowered = (text or "").lower()
138
- categories = (
139
- ("fbr", ("fbr", "taxpayer", "tax refund", "ایف بی آر", "ٹیکس")),
140
- ("bank", ("bank", "hbl", "ubl", "meezan", "alfalah", "بینک")),
141
- ("wallet", ("easypaisa", "jazzcash", "wallet", "ایزی پیسہ", "جاز کیش")),
142
- (
143
- "utility",
144
- ("electricity", "gas bill", "utility", "lesco", "k-electric", "بجلی", "گیس بل"),
145
- ),
146
- (
147
- "traffic_challan",
148
- ("challan", "traffic fine", "traffic violation", "چالان", "ٹریفک جرمانہ"),
149
- ),
150
- (
151
- "courier",
152
- (
153
- "parcel",
154
- "courier",
155
- "delivery",
156
- "pakistan post",
157
- "leopards",
158
- "tcs",
159
- "پارسل",
160
- "کوریئر",
161
- "ڈیلیوری",
162
- "پاکستان پوسٹ",
163
- ),
164
- ),
165
- ("customs", ("customs", "duty", "کسٹمز")),
166
- (
167
- "university",
168
- ("university", "admission", "scholarship", "hec", "یونیورسٹی", "داخلہ"),
169
- ),
170
- ("job", ("job", "salary", "recruiter", "employment", "نوکری", "تنخواہ")),
171
- (
172
- "marketplace",
173
- ("buyer", "seller", "marketplace", "whatsapp", "خریدار", "فروخت", "واٹس ایپ"),
174
- ),
175
- )
176
- for category, terms in categories:
177
  if any(term in lowered for term in terms):
178
  return category
179
  if signals["courier"]:
@@ -206,6 +313,8 @@ def safe_description(category: str, signals: dict[str, bool]) -> str:
206
  "courier": "courier",
207
  "challan": "challan",
208
  "account_threat": "account-threat",
 
 
209
  }
210
  active = [signal_labels[name] for name, enabled in signals.items() if enabled][:4]
211
  suffix = f" with {', '.join(active)} signals" if active else " with no mapped signals"
@@ -268,6 +377,23 @@ def assessment_evidence(assessment: dict[str, Any] | None) -> str:
268
  return " ".join(values)[:4000]
269
 
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  def build_input_profile(
272
  text: str,
273
  image_data_url: str,
@@ -281,6 +407,11 @@ def build_input_profile(
281
  input_type = "image"
282
  else:
283
  input_type = "text"
 
 
 
 
 
284
  classification_text = text
285
  if input_type == "image" and not example_id:
286
  classification_text = " ".join(
@@ -288,12 +419,29 @@ def build_input_profile(
288
  )
289
  signals = detect_signals(classification_text, example_id)
290
  category = detect_category(classification_text, signals, example_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  tactics = [name for name, enabled in signals.items() if enabled]
 
 
 
 
292
  return {
293
  "input": (
294
  f"text: {redact_text(text)}"
295
  if input_type == "text"
296
- else f"image: {safe_description(category, signals)}"
297
  ),
298
  "input_category": category,
299
  "urgency": signals["urgency"],
@@ -318,13 +466,13 @@ def build_trace_record(
318
  example_id,
319
  assessment,
320
  )
321
- classification_text = text
322
- if image_data_url and not example_id:
323
- classification_text = " ".join(
324
- part for part in (text, assessment_evidence(assessment)) if part
325
- )
326
- signals = detect_signals(classification_text, example_id)
327
  category = input_profile["input_category"]
 
 
 
 
 
 
328
  assessment = assessment or {}
329
  return {
330
  "trace_id": trace_id,
@@ -346,22 +494,34 @@ def validate_trace(record: Any) -> list[str]:
346
  errors: list[str] = []
347
  if not isinstance(record, dict):
348
  return ["Trace must be an object."]
349
- required = {
350
- "trace_id",
351
- "timestamp",
352
- "input",
353
- "input_category",
354
- "urgency",
355
- "scam_tactics",
356
- "result_summary",
357
- "risk_label",
358
- "reply_draft_policy",
359
- }
360
  missing = required - record.keys()
361
  if missing:
362
  errors.append("Missing fields: " + ", ".join(sorted(missing)))
 
 
 
363
  if record and next(iter(record)) != "trace_id":
364
  errors.append("trace_id must be the first column.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  input_value = record.get("input")
366
  if not (
367
  isinstance(input_value, str)
@@ -371,14 +531,58 @@ def validate_trace(record: Any) -> list[str]:
371
  )
372
  ):
373
  errors.append("Input must use a fixed text: or image: description.")
374
- if not isinstance(record.get("input_category"), str):
375
- errors.append("Input category must be a string.")
 
 
 
 
 
 
376
  if not isinstance(record.get("urgency"), bool):
377
  errors.append("Urgency must be boolean.")
378
- if not isinstance(record.get("result_summary"), str):
379
- errors.append("Result summary must be a string.")
 
 
 
 
 
 
 
 
 
 
 
 
380
  if record.get("risk_label") not in RISK_LABELS:
381
  errors.append("Invalid risk label.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  if any(isinstance(value, (dict, list)) for value in record.values()):
383
  errors.append("Trace columns must contain scalar values only.")
384
  forbidden_keys = {
 
67
  r"\b(?:account|sim|service|electricity)\b.{0,50}"
68
  r"\b(?:block|blocked|suspend|closed|disconnect)\b"
69
  ),
70
+ "off_platform_contact": (
71
+ r"\b(?:move|continue|contact|chat|message)\b.{0,50}"
72
+ r"\b(?:whatsapp|telegram|outside|another (?:app|platform)|phone number)\b|"
73
+ r"\bwhatsapp\b|واٹس ایپ"
74
+ ),
75
+ "impersonation": (
76
+ r"\b(?:claims? to be|pretends? to be|impersonat(?:e|es|ing|ion)|"
77
+ r"fake (?:sender|branding|authority)|unverified (?:sender|identity)|"
78
+ r"(?:sender )?identity is unverified|official branding)\b|جعلی|نقالی"
79
+ ),
80
  }
81
  EXAMPLE_PROFILES = {
82
  "text-courier": ("text", "courier", {"link", "urgency", "payment", "courier"}),
 
124
  re.I,
125
  )
126
  TITLE_CASE_PATTERN = re.compile(r"\b[A-Z][a-z]{2,}\b")
127
+ TRACE_FIELDS = (
128
+ "trace_id",
129
+ "timestamp",
130
+ "input",
131
+ "input_category",
132
+ "urgency",
133
+ "scam_tactics",
134
+ "result_summary",
135
+ "risk_label",
136
+ "reply_draft_policy",
137
+ )
138
+ REPLY_DRAFT_POLICIES = {"allowed", "suppressed", "not_applicable"}
139
+ RESIDUAL_IDENTIFIER_PATTERNS = {
140
+ "URL": re.compile(r"https?://|www\.", re.I),
141
+ "email": re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
142
+ "CNIC": re.compile(r"\b\d{5}-\d{7}-\d\b"),
143
+ "phone number": re.compile(r"(?<!\d)(?:\+?92[- ]?|0)?3\d{2}[- ]?\d{7}(?!\d)"),
144
+ "account number": re.compile(r"\bPK\d{2}[A-Z0-9]{10,30}\b", re.I),
145
+ "card number": re.compile(r"(?<!\d)(?:\d[ -]?){12,19}(?!\d)"),
146
+ }
147
+ CATEGORY_TERMS = (
148
+ ("fbr", ("fbr", "taxpayer", "tax refund", "revenue board", "ایف بی آر", "ٹیکس")),
149
+ (
150
+ "bank",
151
+ (
152
+ "bank",
153
+ "banking",
154
+ "hbl",
155
+ "ubl",
156
+ "meezan",
157
+ "alfalah",
158
+ "debit card",
159
+ "credit card",
160
+ "بینک",
161
+ ),
162
+ ),
163
+ (
164
+ "wallet",
165
+ (
166
+ "easypaisa",
167
+ "easy paisa",
168
+ "jazzcash",
169
+ "mobile wallet",
170
+ "ایزی پیسہ",
171
+ "جاز کیش",
172
+ ),
173
+ ),
174
+ (
175
+ "utility",
176
+ (
177
+ "electricity",
178
+ "gas bill",
179
+ "utility bill",
180
+ "lesco",
181
+ "k-electric",
182
+ "meter",
183
+ "بجلی",
184
+ "گیس بل",
185
+ ),
186
+ ),
187
+ (
188
+ "traffic_challan",
189
+ (
190
+ "challan",
191
+ "traffic fine",
192
+ "traffic violation",
193
+ "e-challan",
194
+ "vehicle fine",
195
+ "چالان",
196
+ "ٹریفک جرمانہ",
197
+ ),
198
+ ),
199
+ (
200
+ "courier",
201
+ (
202
+ "parcel",
203
+ "package",
204
+ "courier",
205
+ "delivery",
206
+ "shipment",
207
+ "consignment",
208
+ "pakistan post",
209
+ "leopards",
210
+ "tcs",
211
+ "پارسل",
212
+ "کوریئر",
213
+ "ڈیلیوری",
214
+ "پاکستان پوسٹ",
215
+ ),
216
+ ),
217
+ ("customs", ("customs", "custom duty", "import duty", "کسٹمز")),
218
+ (
219
+ "university",
220
+ (
221
+ "university",
222
+ "admission",
223
+ "scholarship",
224
+ "student portal",
225
+ "tuition",
226
+ "hec",
227
+ "یونیورسٹی",
228
+ "داخلہ",
229
+ "اسکالرشپ",
230
+ ),
231
+ ),
232
+ (
233
+ "job",
234
+ (
235
+ "job",
236
+ "salary",
237
+ "recruiter",
238
+ "recruitment",
239
+ "employment",
240
+ "interview",
241
+ "work from home",
242
+ "نوکری",
243
+ "تنخواہ",
244
+ ),
245
+ ),
246
+ (
247
+ "marketplace",
248
+ (
249
+ "buyer",
250
+ "seller",
251
+ "marketplace",
252
+ "listing",
253
+ "product",
254
+ "whatsapp",
255
+ "move the conversation",
256
+ "خریدار",
257
+ "فروخت",
258
+ "واٹس ایپ",
259
+ ),
260
+ ),
261
+ )
262
 
263
 
264
  def detect_signals(text: str, example_id: str = "") -> dict[str, bool]:
 
280
  if signals["challan"]:
281
  return "traffic_challan"
282
  lowered = (text or "").lower()
283
+ for category, terms in CATEGORY_TERMS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  if any(term in lowered for term in terms):
285
  return category
286
  if signals["courier"]:
 
313
  "courier": "courier",
314
  "challan": "challan",
315
  "account_threat": "account-threat",
316
+ "off_platform_contact": "off-platform-contact",
317
+ "impersonation": "impersonation",
318
  }
319
  active = [signal_labels[name] for name, enabled in signals.items() if enabled][:4]
320
  suffix = f" with {', '.join(active)} signals" if active else " with no mapped signals"
 
377
  return " ".join(values)[:4000]
378
 
379
 
380
+ def structured_assessment_profile(
381
+ assessment: dict[str, Any] | None,
382
+ ) -> tuple[str, dict[str, bool]] | None:
383
+ if not isinstance(assessment, dict):
384
+ return None
385
+ category = assessment.get("trace_category")
386
+ tactics = assessment.get("trace_tactics")
387
+ if category not in CATEGORY_DISPLAY_NAMES or not isinstance(tactics, list):
388
+ return None
389
+ if any(tactic not in SIGNAL_PATTERNS for tactic in tactics):
390
+ return None
391
+ return category, {
392
+ name: name in tactics
393
+ for name in SIGNAL_PATTERNS
394
+ }
395
+
396
+
397
  def build_input_profile(
398
  text: str,
399
  image_data_url: str,
 
407
  input_type = "image"
408
  else:
409
  input_type = "text"
410
+ structured_profile = (
411
+ structured_assessment_profile(assessment)
412
+ if input_type == "image" and not example_id
413
+ else None
414
+ )
415
  classification_text = text
416
  if input_type == "image" and not example_id:
417
  classification_text = " ".join(
 
419
  )
420
  signals = detect_signals(classification_text, example_id)
421
  category = detect_category(classification_text, signals, example_id)
422
+ if structured_profile:
423
+ structured_category, structured_signals = structured_profile
424
+ confirmed_tactics = {
425
+ name
426
+ for name, enabled in structured_signals.items()
427
+ if enabled and signals[name]
428
+ }
429
+ if (
430
+ category == "unknown"
431
+ and structured_category != "unknown"
432
+ and confirmed_tactics
433
+ ):
434
+ category = structured_category
435
  tactics = [name for name, enabled in signals.items() if enabled]
436
+ if input_type == "image" and not assessment and not example_id:
437
+ input_description = "image: Assessment unavailable"
438
+ else:
439
+ input_description = f"image: {safe_description(category, signals)}"
440
  return {
441
  "input": (
442
  f"text: {redact_text(text)}"
443
  if input_type == "text"
444
+ else input_description
445
  ),
446
  "input_category": category,
447
  "urgency": signals["urgency"],
 
466
  example_id,
467
  assessment,
468
  )
 
 
 
 
 
 
469
  category = input_profile["input_category"]
470
+ tactic_values = (
471
+ []
472
+ if input_profile["scam_tactics"] == "none"
473
+ else input_profile["scam_tactics"].split(", ")
474
+ )
475
+ signals = {name: name in tactic_values for name in SIGNAL_PATTERNS}
476
  assessment = assessment or {}
477
  return {
478
  "trace_id": trace_id,
 
494
  errors: list[str] = []
495
  if not isinstance(record, dict):
496
  return ["Trace must be an object."]
497
+ required = set(TRACE_FIELDS)
 
 
 
 
 
 
 
 
 
 
498
  missing = required - record.keys()
499
  if missing:
500
  errors.append("Missing fields: " + ", ".join(sorted(missing)))
501
+ unexpected = record.keys() - required
502
+ if unexpected:
503
+ errors.append("Unexpected fields: " + ", ".join(sorted(unexpected)))
504
  if record and next(iter(record)) != "trace_id":
505
  errors.append("trace_id must be the first column.")
506
+ trace_id = record.get("trace_id")
507
+ try:
508
+ parsed_trace_id = uuid.UUID(trace_id) if isinstance(trace_id, str) else None
509
+ if parsed_trace_id is None or str(parsed_trace_id) != trace_id:
510
+ raise ValueError
511
+ except (ValueError, AttributeError):
512
+ errors.append("Trace ID must be a canonical UUID.")
513
+ timestamp = record.get("timestamp")
514
+ try:
515
+ parsed_timestamp = (
516
+ datetime.fromisoformat(timestamp) if isinstance(timestamp, str) else None
517
+ )
518
+ if (
519
+ parsed_timestamp is None
520
+ or parsed_timestamp.utcoffset() != timezone.utc.utcoffset(None)
521
+ ):
522
+ raise ValueError
523
+ except (ValueError, TypeError):
524
+ errors.append("Timestamp must be an ISO 8601 UTC value.")
525
  input_value = record.get("input")
526
  if not (
527
  isinstance(input_value, str)
 
531
  )
532
  ):
533
  errors.append("Input must use a fixed text: or image: description.")
534
+ elif len(input_value) > 506:
535
+ errors.append("Input exceeds the 500-character content limit.")
536
+ else:
537
+ for label, pattern in RESIDUAL_IDENTIFIER_PATTERNS.items():
538
+ if pattern.search(input_value):
539
+ errors.append(f"Input contains an unredacted {label}.")
540
+ if record.get("input_category") not in CATEGORY_DISPLAY_NAMES:
541
+ errors.append("Invalid input category.")
542
  if not isinstance(record.get("urgency"), bool):
543
  errors.append("Urgency must be boolean.")
544
+ tactics = record.get("scam_tactics")
545
+ tactic_values: list[str] = []
546
+ if not isinstance(tactics, str):
547
+ errors.append("Scam tactics must be a string.")
548
+ else:
549
+ tactic_values = [] if tactics == "none" else tactics.split(", ")
550
+ if (
551
+ any(value not in SIGNAL_PATTERNS for value in tactic_values)
552
+ or len(tactic_values) != len(set(tactic_values))
553
+ ):
554
+ errors.append("Invalid scam tactics.")
555
+ result = record.get("result_summary")
556
+ if not isinstance(result, str) or not result or len(result) > 500:
557
+ errors.append("Result summary must be a non-empty string of at most 500 characters.")
558
  if record.get("risk_label") not in RISK_LABELS:
559
  errors.append("Invalid risk label.")
560
+ if record.get("reply_draft_policy") not in REPLY_DRAFT_POLICIES:
561
+ errors.append("Invalid reply draft policy.")
562
+ risk_label = record.get("risk_label")
563
+ category = record.get("input_category")
564
+ if risk_label in RISK_LABELS and category in CATEGORY_DISPLAY_NAMES:
565
+ expected_policy = (
566
+ "allowed"
567
+ if risk_label in {"Verify first", "Suspicious"}
568
+ else "suppressed"
569
+ if risk_label != "none"
570
+ else "not_applicable"
571
+ )
572
+ if record.get("reply_draft_policy") != expected_policy:
573
+ errors.append("Reply draft policy does not match the risk label.")
574
+ signals = {name: name in tactic_values for name in SIGNAL_PATTERNS}
575
+ expected_summary = result_summary(risk_label, category, signals)
576
+ if result != expected_summary:
577
+ errors.append("Result summary does not match the deterministic fields.")
578
+ if isinstance(input_value, str) and input_value.startswith("image: "):
579
+ expected_input = f"image: {safe_description(category, signals)}"
580
+ unavailable_input = (
581
+ risk_label == "none"
582
+ and input_value == "image: Assessment unavailable"
583
+ )
584
+ if input_value != expected_input and not unavailable_input:
585
+ errors.append("Image input does not match the deterministic fields.")
586
  if any(isinstance(value, (dict, list)) for value in record.values()):
587
  errors.append("Trace columns must contain scalar values only.")
588
  forbidden_keys = {
traces/scripts/analyze_trace_dataset.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Analyze local or Hugging Face privacy-safe trace JSONL files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections import Counter
9
+ from pathlib import Path
10
+ from tempfile import TemporaryDirectory
11
+ from typing import Any
12
+
13
+ ROOT = Path(__file__).resolve().parents[2]
14
+ sys.path.insert(0, str(ROOT))
15
+
16
+ from traces.runtime import DATASET_REPO, validate_trace
17
+
18
+
19
+ def load_records(paths: list[Path]) -> tuple[list[dict[str, Any]], list[str]]:
20
+ records: list[dict[str, Any]] = []
21
+ errors: list[str] = []
22
+ trace_ids: set[str] = set()
23
+ for path in paths:
24
+ for line_number, line in enumerate(
25
+ path.read_text(encoding="utf-8").splitlines(),
26
+ start=1,
27
+ ):
28
+ if not line.strip():
29
+ continue
30
+ try:
31
+ record = json.loads(line)
32
+ except json.JSONDecodeError as exc:
33
+ errors.append(f"{path}:{line_number}: invalid JSON: {exc}")
34
+ continue
35
+ if not isinstance(record, dict):
36
+ errors.append(f"{path}:{line_number}: Trace must be an object.")
37
+ continue
38
+ trace_id = record.get("trace_id")
39
+ if trace_id in trace_ids:
40
+ errors.append(f"{path}:{line_number}: Duplicate trace ID: {trace_id}")
41
+ elif isinstance(trace_id, str):
42
+ trace_ids.add(trace_id)
43
+ errors.extend(
44
+ f"{path}:{line_number}: {error}"
45
+ for error in validate_trace(record)
46
+ )
47
+ records.append(record)
48
+ return records, errors
49
+
50
+
51
+ def summarize(records: list[dict[str, Any]], file_count: int) -> dict[str, Any]:
52
+ inputs = Counter(
53
+ record.get("input")
54
+ for record in records
55
+ if isinstance(record.get("input"), str)
56
+ )
57
+ duplicate_rows = sum(count - 1 for count in inputs.values() if count > 1)
58
+ return {
59
+ "files": file_count,
60
+ "rows": len(records),
61
+ "unique_inputs": len(inputs),
62
+ "duplicate_input_rows": duplicate_rows,
63
+ "duplicate_input_rate": (
64
+ round(duplicate_rows / len(records), 4) if records else 0
65
+ ),
66
+ "unclassified_images": sum(
67
+ record.get("input")
68
+ == "image: Unclassified content with no mapped signals"
69
+ for record in records
70
+ ),
71
+ "unavailable_image_assessments": sum(
72
+ record.get("input") == "image: Assessment unavailable"
73
+ for record in records
74
+ ),
75
+ "incomplete_assessments": sum(
76
+ record.get("risk_label") == "none" for record in records
77
+ ),
78
+ "risk_labels": dict(Counter(
79
+ str(record.get("risk_label", "[missing]")) for record in records
80
+ ).most_common()),
81
+ "input_categories": dict(Counter(
82
+ str(record.get("input_category", "[missing]")) for record in records
83
+ ).most_common()),
84
+ "input_types": dict(Counter(
85
+ record["input"].split(":", 1)[0]
86
+ for record in records
87
+ if isinstance(record.get("input"), str)
88
+ ).most_common()),
89
+ "top_repeated_inputs": [
90
+ {"count": count, "input": input_value}
91
+ for input_value, count in inputs.most_common(10)
92
+ if count > 1
93
+ ],
94
+ }
95
+
96
+
97
+ def hub_paths(repo_id: str, directory: Path) -> list[Path]:
98
+ try:
99
+ from huggingface_hub import HfApi, hf_hub_download
100
+ except ImportError as exc:
101
+ raise RuntimeError(
102
+ "Install huggingface_hub to analyze a Hub dataset."
103
+ ) from exc
104
+ api = HfApi()
105
+ filenames = [
106
+ name
107
+ for name in api.list_repo_files(repo_id, repo_type="dataset")
108
+ if name.endswith(".jsonl")
109
+ ]
110
+ return [
111
+ Path(hf_hub_download(
112
+ repo_id,
113
+ filename,
114
+ repo_type="dataset",
115
+ local_dir=directory,
116
+ ))
117
+ for filename in filenames
118
+ ]
119
+
120
+
121
+ def main() -> int:
122
+ parser = argparse.ArgumentParser(description=__doc__)
123
+ parser.add_argument("paths", nargs="*", type=Path)
124
+ parser.add_argument("--repo-id", default=DATASET_REPO)
125
+ parser.add_argument("--json", action="store_true", dest="as_json")
126
+ args = parser.parse_args()
127
+
128
+ with TemporaryDirectory() as directory:
129
+ paths = args.paths or hub_paths(args.repo_id, Path(directory))
130
+ records, errors = load_records(paths)
131
+ report = summarize(records, len(paths))
132
+ report["validation_errors"] = len(errors)
133
+
134
+ if args.as_json:
135
+ print(json.dumps(report, ensure_ascii=False, indent=2))
136
+ else:
137
+ print(f"Files: {report['files']}")
138
+ print(f"Rows: {report['rows']}")
139
+ print(f"Validation errors: {report['validation_errors']}")
140
+ print(f"Unique inputs: {report['unique_inputs']}")
141
+ print(
142
+ "Repeated-input rows: "
143
+ f"{report['duplicate_input_rows']} "
144
+ f"({report['duplicate_input_rate']:.1%})"
145
+ )
146
+ print(f"Unclassified images: {report['unclassified_images']}")
147
+ print(
148
+ "Unavailable image assessments: "
149
+ f"{report['unavailable_image_assessments']}"
150
+ )
151
+ print(f"Incomplete assessments: {report['incomplete_assessments']}")
152
+ print(f"Risk labels: {report['risk_labels']}")
153
+ print(f"Input categories: {report['input_categories']}")
154
+ if errors:
155
+ print("\n".join(errors), file=sys.stderr)
156
+ return 1 if errors else 0
157
+
158
+
159
+ if __name__ == "__main__":
160
+ raise SystemExit(main())
traces/scripts/validate_traces.py CHANGED
@@ -17,6 +17,7 @@ from traces.runtime import validate_trace
17
  def validate_file(path: Path) -> tuple[int, list[str]]:
18
  count = 0
19
  errors: list[str] = []
 
20
  for line_number, line in enumerate(
21
  path.read_text(encoding="utf-8").splitlines(),
22
  start=1,
@@ -29,6 +30,11 @@ def validate_file(path: Path) -> tuple[int, list[str]]:
29
  except json.JSONDecodeError as exc:
30
  errors.append(f"{path}:{line_number}: invalid JSON: {exc}")
31
  continue
 
 
 
 
 
32
  for error in validate_trace(record):
33
  errors.append(f"{path}:{line_number}: {error}")
34
  return count, errors
 
17
  def validate_file(path: Path) -> tuple[int, list[str]]:
18
  count = 0
19
  errors: list[str] = []
20
+ trace_ids: set[str] = set()
21
  for line_number, line in enumerate(
22
  path.read_text(encoding="utf-8").splitlines(),
23
  start=1,
 
30
  except json.JSONDecodeError as exc:
31
  errors.append(f"{path}:{line_number}: invalid JSON: {exc}")
32
  continue
33
+ trace_id = record.get("trace_id") if isinstance(record, dict) else None
34
+ if trace_id in trace_ids:
35
+ errors.append(f"{path}:{line_number}: Duplicate trace ID: {trace_id}")
36
+ elif isinstance(trace_id, str):
37
+ trace_ids.add(trace_id)
38
  for error in validate_trace(record):
39
  errors.append(f"{path}:{line_number}: {error}")
40
  return count, errors