Abid Ali Awan Codex commited on
Commit
09e22a1
·
1 Parent(s): 8380712

Add readable redacted trace summaries

Browse files

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

README.md CHANGED
@@ -127,11 +127,14 @@ 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 a safe input type or image description, input category,
131
- urgency, fixed signals, and result counts. The input field uses a safe
132
- `text: ...` or `image: ...` description generated from fixed templates.
133
- It never stores raw or redacted messages, screenshots, links, identifiers,
134
- model explanations, reply text, exceptions, or credentials.
 
 
 
135
 
136
  Safe records are queued without blocking the response, written in batches of
137
  20 or after 60 seconds, and uploaded as unique JSONL shards. Hub failures leave
 
127
  turn it off before submitting.
128
 
129
  Trace creation is deterministic Python logic and makes no additional model
130
+ request. Text inputs are aggressively redacted and capped at 500 characters;
131
+ images use a fixed `image: ...` description without OCR or image storage. The
132
+ trace also records category, urgency, fixed signals, result counts, and a
133
+ deterministic `result_summary` explaining the scam pattern and risk label.
134
+ All trace columns are flat scalar values; no dataset cell contains a nested
135
+ dictionary.
136
+ It never stores raw messages, screenshots, links, detected identifiers, model
137
+ explanations, reply text, exceptions, or credentials.
138
 
139
  Safe records are queued without blocking the response, written in batches of
140
  20 or after 60 seconds, and uploaded as unique JSONL shards. Hub failures leave
static/index.html CHANGED
@@ -78,7 +78,7 @@
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">
 
78
  <input id="saveTrace" type="checkbox" checked>
79
  <span>
80
  <strong>Publish privacy-safe trace</strong>
81
+ <small>Stores automated redacted text or an image description. Raw text, screenshots, links, identifiers, and model text are not stored.</small>
82
  </span>
83
  </label>
84
  <div class="form-actions">
tests/test_tracing.py CHANGED
@@ -67,6 +67,12 @@ class TraceTests(unittest.TestCase):
67
  self.assertTrue(image_record["input"].startswith("image: "))
68
  self.assertEqual(image_record["input_category"], "unknown")
69
  self.assertFalse(image_record["urgency"])
 
 
 
 
 
 
70
  for removed in (
71
  "pipeline_steps",
72
  "cache",
@@ -88,12 +94,21 @@ class TraceTests(unittest.TestCase):
88
  example_id="",
89
  assessment=None,
90
  )
91
- self.assertEqual(
92
- text_record["input"],
93
- "text: Courier-style content with urgency, payment, courier signals",
94
- )
95
  self.assertEqual(text_record["input_category"], "courier")
96
  self.assertTrue(text_record["urgency"])
 
 
 
 
 
 
 
 
 
 
97
 
98
  def test_opt_out_does_not_queue_trace(self) -> None:
99
  with patch("app.queue_trace") as queue_mock:
 
67
  self.assertTrue(image_record["input"].startswith("image: "))
68
  self.assertEqual(image_record["input_category"], "unknown")
69
  self.assertFalse(image_record["urgency"])
70
+ self.assertIn(
71
+ "does not confirm a new scam type",
72
+ image_record["result_summary"],
73
+ )
74
+ self.assertIn("Strong scam indicators", image_record["result_summary"])
75
+ self.assertEqual(image_record["input_storage"], "image_description_only")
76
  for removed in (
77
  "pipeline_steps",
78
  "cache",
 
94
  example_id="",
95
  assessment=None,
96
  )
97
+ self.assertTrue(text_record["input"].startswith("text: "))
98
+ self.assertIn("courier payment required today", text_record["input"])
99
+ self.assertNotIn("Urgent", text_record["input"])
 
100
  self.assertEqual(text_record["input_category"], "courier")
101
  self.assertTrue(text_record["urgency"])
102
+ self.assertIn("Known courier pattern", text_record["result_summary"])
103
+ self.assertIn(
104
+ "No completed assessment result was available",
105
+ text_record["result_summary"],
106
+ )
107
+ self.assertEqual(text_record["input_storage"], "redacted_text")
108
+ self.assertFalse(any(
109
+ isinstance(value, (dict, list))
110
+ for value in text_record.values()
111
+ ))
112
 
113
  def test_opt_out_does_not_queue_trace(self) -> None:
114
  with patch("app.queue_trace") as queue_mock:
traces/data/trace_samples.jsonl CHANGED
@@ -1,6 +1,6 @@
1
- {"input": "text: Courier-style content with link, urgency, payment, courier signals", "input_category": "courier", "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "result": {"red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "signals": {"account_threat": false, "challan": false, "cnic": false, "courier": true, "credentials": false, "link": true, "otp": false, "payment": true, "refund_or_prize": false}, "timestamp": "2026-06-07T06:37:30.001011+00:00", "trace_id": "2d2fda51-9f65-4ffe-a884-a79582de4b86", "urgency": true}
2
- {"input": "text: FBR-style content with CNIC, credential, urgency, payment signals", "input_category": "fbr", "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "result": {"red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "signals": {"account_threat": false, "challan": false, "cnic": true, "courier": false, "credentials": true, "link": false, "otp": false, "payment": true, "refund_or_prize": true}, "timestamp": "2026-06-07T06:37:30.001113+00:00", "trace_id": "57521c66-f3df-412c-8c2f-9638fac910c7", "urgency": true}
3
- {"input": "text: Bank-style content with OTP, urgency, account-threat signals", "input_category": "bank", "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "result": {"red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 5}, "signals": {"account_threat": true, "challan": false, "cnic": false, "courier": false, "credentials": false, "link": false, "otp": true, "payment": false, "refund_or_prize": false}, "timestamp": "2026-06-07T06:37:30.001192+00:00", "trace_id": "fa84e753-4492-473f-b797-b94eee8724bc", "urgency": true}
4
- {"input": "image: Courier-style content with link, urgency, courier signals", "input_category": "courier", "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "result": {"red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "signals": {"account_threat": false, "challan": false, "cnic": false, "courier": true, "credentials": false, "link": true, "otp": false, "payment": false, "refund_or_prize": false}, "timestamp": "2026-06-07T06:37:30.001301+00:00", "trace_id": "eaf5013c-adb7-4b06-9e4c-4e785e4c776a", "urgency": true}
5
- {"input": "image: Marketplace-style content with credential signals", "input_category": "marketplace", "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "result": {"red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "signals": {"account_threat": false, "challan": false, "cnic": false, "courier": false, "credentials": true, "link": false, "otp": false, "payment": false, "refund_or_prize": false}, "timestamp": "2026-06-07T06:37:30.001851+00:00", "trace_id": "dd5000f9-d612-4ee8-a1ce-d9c41a8ef347", "urgency": false}
6
- {"input": "image: Traffic-challan-style content with link, urgency, payment, challan signals", "input_category": "traffic_challan", "privacy": {"exception_text_stored": false, "identifiers_stored": false, "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false}, "result": {"red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "risk_label": "Likely scam", "safe_next_step_count": 4}, "signals": {"account_threat": false, "challan": true, "cnic": false, "courier": false, "credentials": false, "link": true, "otp": false, "payment": true, "refund_or_prize": false}, "timestamp": "2026-06-07T06:37:30.002064+00:00", "trace_id": "19c0628b-a2ff-45ff-a35e-2a1e6a9d9335", "urgency": true}
 
1
+ {"exception_text_stored": false, "identifiers_stored": false, "input": "text: PAKISTAN POST: [NAME_OR_ENTITY] parcel [ADDRESS]. [NAME_OR_ENTITY] Rs. 85 today at [LINK] or the parcel will be destroyed.", "input_category": "courier", "input_storage": "redacted_text", "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false, "red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "result_summary": "Likely scam: Courier-style content with link, urgency, payment, courier signals. Known courier pattern. Strong scam indicators were found; avoid payments, links, and sharing credentials.", "risk_label": "Likely scam", "safe_next_step_count": 4, "scam_tactics": "link, urgency, payment, courier", "signal_account_threat": false, "signal_challan": false, "signal_cnic": false, "signal_courier": true, "signal_credentials": false, "signal_link": true, "signal_otp": false, "signal_payment": true, "signal_refund_or_prize": false, "timestamp": "2026-06-07T06:49:33.145291+00:00", "trace_id": "57c92b49-6166-41d4-9f98-ded53729c963", "urgency": true}
2
+ {"exception_text_stored": false, "identifiers_stored": false, "input": "text: FBR REFUND: [NAME_OR_ENTITY] are eligible for Rs 42,[NUMBER]. [NAME_OR_ENTITY] your CNIC and bank card [REDACTED] at the link today to receive payment.", "input_category": "fbr", "input_storage": "redacted_text", "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false, "red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "result_summary": "Likely scam: FBR-style content with CNIC, credential, urgency, payment signals. Known FBR pattern. Strong scam indicators were found; avoid payments, links, and sharing credentials.", "risk_label": "Likely scam", "safe_next_step_count": 4, "scam_tactics": "cnic, credentials, urgency, payment, refund_or_prize", "signal_account_threat": false, "signal_challan": false, "signal_cnic": true, "signal_courier": false, "signal_credentials": true, "signal_link": false, "signal_otp": false, "signal_payment": true, "signal_refund_or_prize": true, "timestamp": "2026-06-07T06:49:33.145489+00:00", "trace_id": "18b01fe5-eed0-49c8-9457-a6c9f49ccf71", "urgency": true}
3
+ {"exception_text_stored": false, "identifiers_stored": false, "input": "text: HBL [NAME_OR_ENTITY]: [NAME_OR_ENTITY] account [REDACTED] be suspended. [NAME_OR_ENTITY] the OTP [REDACTED] to your phone with our support team immediately.", "input_category": "bank", "input_storage": "redacted_text", "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false, "red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "result_summary": "Likely scam: Bank-style content with OTP, urgency, account-threat signals. Known bank pattern. Strong scam indicators were found; avoid payments, links, and sharing credentials.", "risk_label": "Likely scam", "safe_next_step_count": 5, "scam_tactics": "otp, urgency, account_threat", "signal_account_threat": true, "signal_challan": false, "signal_cnic": false, "signal_courier": false, "signal_credentials": false, "signal_link": false, "signal_otp": true, "signal_payment": false, "signal_refund_or_prize": false, "timestamp": "2026-06-07T06:49:33.145659+00:00", "trace_id": "fc38b636-2907-4ace-890a-c70f2f5350bc", "urgency": true}
4
+ {"exception_text_stored": false, "identifiers_stored": false, "input": "image: Courier-style content with link, urgency, courier signals", "input_category": "courier", "input_storage": "image_description_only", "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false, "red_flag_count": 4, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "result_summary": "Likely scam: Courier-style content with link, urgency, courier signals. Known courier pattern. Strong scam indicators were found; avoid payments, links, and sharing credentials.", "risk_label": "Likely scam", "safe_next_step_count": 4, "scam_tactics": "link, urgency, courier", "signal_account_threat": false, "signal_challan": false, "signal_cnic": false, "signal_courier": true, "signal_credentials": false, "signal_link": true, "signal_otp": false, "signal_payment": false, "signal_refund_or_prize": false, "timestamp": "2026-06-07T06:49:33.145799+00:00", "trace_id": "d83cb31c-44bb-4fc1-8d13-68776e509b33", "urgency": true}
5
+ {"exception_text_stored": false, "identifiers_stored": false, "input": "image: Marketplace-style content with credential signals", "input_category": "marketplace", "input_storage": "image_description_only", "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false, "red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "result_summary": "Likely scam: Marketplace-style content with credential signals. Known marketplace pattern. Strong scam indicators were found; avoid payments, links, and sharing credentials.", "risk_label": "Likely scam", "safe_next_step_count": 4, "scam_tactics": "credentials", "signal_account_threat": false, "signal_challan": false, "signal_cnic": false, "signal_courier": false, "signal_credentials": true, "signal_link": false, "signal_otp": false, "signal_payment": false, "signal_refund_or_prize": false, "timestamp": "2026-06-07T06:49:33.146161+00:00", "trace_id": "aed94a0c-db03-48fc-92f7-ec8ba14b9124", "urgency": false}
6
+ {"exception_text_stored": false, "identifiers_stored": false, "input": "image: Traffic-challan-style content with link, urgency, payment, challan signals", "input_category": "traffic_challan", "input_storage": "image_description_only", "raw_image_stored": false, "raw_input_stored": false, "raw_model_output_stored": false, "red_flag_count": 3, "reply_draft_policy": "suppressed", "reply_draft_returned": false, "result_summary": "Likely scam: Traffic-challan-style content with link, urgency, payment, challan signals. Known traffic-challan pattern. Strong scam indicators were found; avoid payments, links, and sharing credentials.", "risk_label": "Likely scam", "safe_next_step_count": 4, "scam_tactics": "link, urgency, payment, challan", "signal_account_threat": false, "signal_challan": true, "signal_cnic": false, "signal_courier": false, "signal_credentials": false, "signal_link": true, "signal_otp": false, "signal_payment": true, "signal_refund_or_prize": false, "timestamp": "2026-06-07T06:49:33.146349+00:00", "trace_id": "bf534dfe-991e-4291-93d5-5994f0964c19", "urgency": true}
traces/dataset_card.md CHANGED
@@ -34,13 +34,23 @@ and convert it into allow-listed categories, booleans, buckets, and counts.
34
  ## Fields
35
 
36
  - Trace identity: random trace ID and UTC timestamp
37
- - `input`: a fixed-template `text: ...` or `image: ...` description
 
38
  - `input_category`: deterministic category such as courier, bank, or FBR
39
  - `urgency`: deterministic boolean urgency signal
40
- - Deterministic signals: OTP, CNIC, credentials, link, payment,
41
- refund/prize, courier, challan, and account threat
42
- - Final risk label and output item counts
43
- - Explicit privacy flags
 
 
 
 
 
 
 
 
 
44
 
45
  Records do not contain pipeline steps, cache fields, app commits, failure
46
  details, request source, schema versions, size buckets, language hints, or
@@ -50,7 +60,7 @@ Modal metadata.
50
 
51
  The dataset never stores:
52
 
53
- - Raw or redacted message text
54
  - Screenshots, image bytes, or base64
55
  - URLs, phone numbers, CNICs, names, addresses, account/card numbers, or
56
  tracking numbers
@@ -58,7 +68,13 @@ The dataset never stores:
58
  output
59
  - Exceptions, credentials, tokens, or endpoint headers
60
 
61
- Summaries use fixed templates. Regex detection happens transiently in memory.
 
 
 
 
 
 
62
  Users see a checked trace disclosure in the app and may opt out before each
63
  request.
64
 
@@ -66,13 +82,13 @@ request.
66
 
67
  Seed traces represent the six public examples bundled with Pakistan Notice
68
  Helper. Runtime traces may represent successful, rejected, or failed requests.
69
- A trace reports whether the existing Modal request occurred, but trace
70
- generation itself does not invoke the model.
71
 
72
  ## Limitations
73
 
74
  - Regex signals and category detection are approximate.
75
- - Duration and input sizes are deliberately bucketed.
 
76
  - The dataset cannot reproduce original messages or screenshots.
77
  - A risk label is safety guidance, not official verification.
78
 
 
34
  ## Fields
35
 
36
  - Trace identity: random trace ID and UTC timestamp
37
+ - `input`: aggressively redacted, length-limited text for text submissions, or
38
+ a fixed-template `image: ...` description
39
  - `input_category`: deterministic category such as courier, bank, or FBR
40
  - `urgency`: deterministic boolean urgency signal
41
+ - `result_summary`: deterministic summary of the mapped pattern, whether it is
42
+ known or unclassified, and why the risk label matters
43
+ - `scam_tactics`: readable comma-separated tactics
44
+ - Flat boolean signal columns such as `signal_link`, `signal_payment`, and
45
+ `signal_credentials`
46
+ - Flat result columns such as `risk_label`, `red_flag_count`, and
47
+ `reply_draft_policy`
48
+ - `input_storage`: `redacted_text` or `image_description_only`
49
+ - Flat privacy flags confirming that raw input, images, identifiers, model
50
+ output, and exception text are not stored
51
+
52
+ Every dataset cell is a scalar string, number, or boolean. No column contains a
53
+ dictionary or nested object, which keeps the Hugging Face table easy to read.
54
 
55
  Records do not contain pipeline steps, cache fields, app commits, failure
56
  details, request source, schema versions, size buckets, language hints, or
 
60
 
61
  The dataset never stores:
62
 
63
+ - Raw message text
64
  - Screenshots, image bytes, or base64
65
  - URLs, phone numbers, CNICs, names, addresses, account/card numbers, or
66
  tracking numbers
 
68
  output
69
  - Exceptions, credentials, tokens, or endpoint headers
70
 
71
+ Text traces store an aggressively redacted form capped at 500 characters.
72
+ Regexes remove common URLs, emails, phones, CNICs, card/account numbers,
73
+ credentials, addresses, tracking IDs, long numbers, and title-case names or
74
+ entities. Regex redaction cannot guarantee removal of every possible
75
+ identifier, so users should opt out when submitting sensitive content.
76
+
77
+ Images store only fixed descriptions; screenshots and OCR text are not stored.
78
  Users see a checked trace disclosure in the app and may opt out before each
79
  request.
80
 
 
82
 
83
  Seed traces represent the six public examples bundled with Pakistan Notice
84
  Helper. Runtime traces may represent successful, rejected, or failed requests.
85
+ Trace generation itself does not invoke the model.
 
86
 
87
  ## Limitations
88
 
89
  - Regex signals and category detection are approximate.
90
+ - Regex redaction may miss unusual personal or confidential information.
91
+ - Novelty is not researched against external threat-intelligence sources.
92
  - The dataset cannot reproduce original messages or screenshots.
93
  - A risk label is safety guidance, not official verification.
94
 
traces/runtime.py CHANGED
@@ -55,6 +55,44 @@ EXAMPLE_PROFILES = {
55
  "image-mobile": ("image", "marketplace", {"credentials"}),
56
  "image-traffic": ("image", "traffic_challan", {"link", "urgency", "payment", "challan"}),
57
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  def detect_signals(text: str, example_id: str = "") -> dict[str, bool]:
@@ -127,6 +165,51 @@ def safe_description(category: str, signals: dict[str, bool]) -> str:
127
  return f"{category_labels[category]} content{suffix}"
128
 
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  def build_input_profile(text: str, image_data_url: str, example_id: str = "") -> dict[str, Any]:
131
  profile = EXAMPLE_PROFILES.get(example_id)
132
  if profile:
@@ -137,12 +220,18 @@ def build_input_profile(text: str, image_data_url: str, example_id: str = "") ->
137
  input_type = "text"
138
  signals = detect_signals(text, example_id)
139
  category = detect_category(text, signals, example_id)
 
140
  return {
141
- "input": f"{input_type}: {safe_description(category, signals)}",
 
 
 
 
142
  "input_category": category,
143
  "urgency": signals["urgency"],
144
- "signals": {
145
- name: enabled
 
146
  for name, enabled in signals.items()
147
  if name != "urgency"
148
  },
@@ -161,33 +250,38 @@ def build_trace_record(
161
  if risk_label not in RISK_LABELS:
162
  risk_label = "none"
163
  input_profile = build_input_profile(text, image_data_url, example_id)
 
 
 
164
  return {
165
  "trace_id": trace_id,
166
  "timestamp": datetime.now(timezone.utc).isoformat(),
167
  **input_profile,
168
- "result": {
169
- "risk_label": risk_label,
170
- "red_flag_count": min(len((assessment or {}).get("red_flags", [])), 50),
171
- "safe_next_step_count": min(
172
- len((assessment or {}).get("safe_next_steps", [])),
173
- 50,
174
- ),
175
- "reply_draft_returned": bool((assessment or {}).get("reply_draft")),
176
- "reply_draft_policy": (
177
- "allowed"
178
- if risk_label in {"Verify first", "Suspicious"}
179
- else "suppressed"
180
- if risk_label != "none"
181
- else "not_applicable"
182
- ),
183
- },
184
- "privacy": {
185
- "raw_input_stored": False,
186
- "raw_image_stored": False,
187
- "raw_model_output_stored": False,
188
- "exception_text_stored": False,
189
- "identifiers_stored": False,
190
- },
 
 
191
  }
192
 
193
 
@@ -201,9 +295,28 @@ def validate_trace(record: Any) -> list[str]:
201
  "input",
202
  "input_category",
203
  "urgency",
204
- "signals",
205
- "result",
206
- "privacy",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  }
208
  missing = required - record.keys()
209
  if missing:
@@ -221,11 +334,28 @@ def validate_trace(record: Any) -> list[str]:
221
  errors.append("Input category must be a string.")
222
  if not isinstance(record.get("urgency"), bool):
223
  errors.append("Urgency must be boolean.")
224
- if record.get("result", {}).get("risk_label") not in RISK_LABELS:
 
 
225
  errors.append("Invalid risk label.")
226
- privacy = record.get("privacy", {})
227
- if not isinstance(privacy, dict) or any(privacy.get(key) is not False for key in privacy):
228
- errors.append("Privacy flags must all be false.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  forbidden_keys = {
230
  "schema_version",
231
  "app_commit",
 
55
  "image-mobile": ("image", "marketplace", {"credentials"}),
56
  "image-traffic": ("image", "traffic_challan", {"link", "urgency", "payment", "challan"}),
57
  }
58
+ RESULT_GUIDANCE = {
59
+ "Looks normal": (
60
+ "No strong scam indicators were found, but verify through an official "
61
+ "channel."
62
+ ),
63
+ "Verify first": (
64
+ "Limited or ambiguous warning signs were found; verify independently."
65
+ ),
66
+ "Suspicious": (
67
+ "Multiple warning signs were found; use caution and verify independently."
68
+ ),
69
+ "Likely scam": (
70
+ "Strong scam indicators were found; avoid payments, links, and sharing "
71
+ "credentials."
72
+ ),
73
+ "Inappropriate": "The content was not suitable for a scam-risk assessment.",
74
+ "none": "No completed assessment result was available.",
75
+ }
76
+ CATEGORY_DISPLAY_NAMES = {
77
+ "fbr": "FBR",
78
+ "bank": "bank",
79
+ "wallet": "mobile-wallet",
80
+ "utility": "utility",
81
+ "traffic_challan": "traffic-challan",
82
+ "courier": "courier",
83
+ "customs": "customs",
84
+ "university": "education",
85
+ "job": "job",
86
+ "marketplace": "marketplace",
87
+ "unknown": "unclassified",
88
+ }
89
+ SENSITIVE_VALUE_PATTERN = re.compile(
90
+ r"\b(?:password|passcode|pin|otp|cvv|account(?: number)?|card(?: number)?|"
91
+ r"tracking(?: id| number)?|consignment(?: id| number)?|reference(?: id| number)?)"
92
+ r"\s*(?:is|:|#|-)?\s*[A-Za-z0-9@._/-]{2,}",
93
+ re.I,
94
+ )
95
+ TITLE_CASE_PATTERN = re.compile(r"\b[A-Z][a-z]{2,}\b")
96
 
97
 
98
  def detect_signals(text: str, example_id: str = "") -> dict[str, bool]:
 
165
  return f"{category_labels[category]} content{suffix}"
166
 
167
 
168
+ def redact_text(text: str) -> str:
169
+ value = re.sub(r"\s+", " ", text or "").strip()
170
+ value = SENSITIVE_VALUE_PATTERN.sub(
171
+ lambda match: match.group(0).split()[0] + " [REDACTED]",
172
+ value,
173
+ )
174
+ replacements = (
175
+ (r"https?://\S+|www\.\S+", "[LINK]"),
176
+ (r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b", "[EMAIL]"),
177
+ (r"\b\d{5}-\d{7}-\d\b", "[CNIC]"),
178
+ (r"(?<!\d)(?:\+?92[- ]?|0)?3\d{2}[- ]?\d{7}(?!\d)", "[PHONE]"),
179
+ (r"\bPK\d{2}[A-Z0-9]{10,30}\b", "[ACCOUNT]"),
180
+ (r"(?<!\d)(?:\d[ -]?){12,19}(?!\d)", "[CARD_NUMBER]"),
181
+ (r"\b\d{3,}\b", "[NUMBER]"),
182
+ (
183
+ r"\b(?:address|location|house|street|road|flat)\b"
184
+ r"[^,.;]{0,60}",
185
+ "[ADDRESS]",
186
+ ),
187
+ )
188
+ for pattern, replacement in replacements:
189
+ value = re.sub(pattern, replacement, value, flags=re.I)
190
+ value = TITLE_CASE_PATTERN.sub("[NAME_OR_ENTITY]", value)
191
+ value = re.sub(
192
+ r"(?:\[[A-Z_]+\]\s*){2,}",
193
+ lambda match: match.group(0).strip() + " ",
194
+ value,
195
+ )
196
+ return value[:500] or "[EMPTY]"
197
+
198
+
199
+ def result_summary(
200
+ risk_label: str,
201
+ category: str,
202
+ signals: dict[str, bool],
203
+ ) -> str:
204
+ pattern = safe_description(category, signals)
205
+ novelty = (
206
+ "Unclassified pattern; this does not confirm a new scam type."
207
+ if category == "unknown"
208
+ else f"Known {CATEGORY_DISPLAY_NAMES[category]} pattern."
209
+ )
210
+ return f"{risk_label}: {pattern}. {novelty} {RESULT_GUIDANCE[risk_label]}"
211
+
212
+
213
  def build_input_profile(text: str, image_data_url: str, example_id: str = "") -> dict[str, Any]:
214
  profile = EXAMPLE_PROFILES.get(example_id)
215
  if profile:
 
220
  input_type = "text"
221
  signals = detect_signals(text, example_id)
222
  category = detect_category(text, signals, example_id)
223
+ tactics = [name for name, enabled in signals.items() if enabled]
224
  return {
225
+ "input": (
226
+ f"text: {redact_text(text)}"
227
+ if input_type == "text"
228
+ else f"image: {safe_description(category, signals)}"
229
+ ),
230
  "input_category": category,
231
  "urgency": signals["urgency"],
232
+ "scam_tactics": ", ".join(tactics) if tactics else "none",
233
+ **{
234
+ f"signal_{name}": enabled
235
  for name, enabled in signals.items()
236
  if name != "urgency"
237
  },
 
250
  if risk_label not in RISK_LABELS:
251
  risk_label = "none"
252
  input_profile = build_input_profile(text, image_data_url, example_id)
253
+ signals = detect_signals(text, example_id)
254
+ category = input_profile["input_category"]
255
+ assessment = assessment or {}
256
  return {
257
  "trace_id": trace_id,
258
  "timestamp": datetime.now(timezone.utc).isoformat(),
259
  **input_profile,
260
+ "result_summary": result_summary(risk_label, category, signals),
261
+ "risk_label": risk_label,
262
+ "red_flag_count": min(len(assessment.get("red_flags", [])), 50),
263
+ "safe_next_step_count": min(
264
+ len(assessment.get("safe_next_steps", [])),
265
+ 50,
266
+ ),
267
+ "reply_draft_returned": bool(assessment.get("reply_draft")),
268
+ "reply_draft_policy": (
269
+ "allowed"
270
+ if risk_label in {"Verify first", "Suspicious"}
271
+ else "suppressed"
272
+ if risk_label != "none"
273
+ else "not_applicable"
274
+ ),
275
+ "input_storage": (
276
+ "redacted_text"
277
+ if input_profile["input"].startswith("text: ")
278
+ else "image_description_only"
279
+ ),
280
+ "raw_input_stored": False,
281
+ "raw_image_stored": False,
282
+ "raw_model_output_stored": False,
283
+ "exception_text_stored": False,
284
+ "identifiers_stored": False,
285
  }
286
 
287
 
 
295
  "input",
296
  "input_category",
297
  "urgency",
298
+ "scam_tactics",
299
+ "signal_otp",
300
+ "signal_cnic",
301
+ "signal_credentials",
302
+ "signal_link",
303
+ "signal_payment",
304
+ "signal_refund_or_prize",
305
+ "signal_courier",
306
+ "signal_challan",
307
+ "signal_account_threat",
308
+ "result_summary",
309
+ "risk_label",
310
+ "red_flag_count",
311
+ "safe_next_step_count",
312
+ "reply_draft_returned",
313
+ "reply_draft_policy",
314
+ "input_storage",
315
+ "raw_input_stored",
316
+ "raw_image_stored",
317
+ "raw_model_output_stored",
318
+ "exception_text_stored",
319
+ "identifiers_stored",
320
  }
321
  missing = required - record.keys()
322
  if missing:
 
334
  errors.append("Input category must be a string.")
335
  if not isinstance(record.get("urgency"), bool):
336
  errors.append("Urgency must be boolean.")
337
+ if not isinstance(record.get("result_summary"), str):
338
+ errors.append("Result summary must be a string.")
339
+ if record.get("risk_label") not in RISK_LABELS:
340
  errors.append("Invalid risk label.")
341
+ private_storage_flags = (
342
+ "raw_input_stored",
343
+ "raw_image_stored",
344
+ "raw_model_output_stored",
345
+ "exception_text_stored",
346
+ "identifiers_stored",
347
+ )
348
+ if any(
349
+ record.get(key) is not False for key in private_storage_flags
350
+ ):
351
+ errors.append("Raw/private storage flags must all be false.")
352
+ if record.get("input_storage") not in {
353
+ "redacted_text",
354
+ "image_description_only",
355
+ }:
356
+ errors.append("Invalid input storage category.")
357
+ if any(isinstance(value, (dict, list)) for value in record.values()):
358
+ errors.append("Trace columns must contain scalar values only.")
359
  forbidden_keys = {
360
  "schema_version",
361
  "app_commit",