Abid Ali Awan Codex commited on
Commit ·
09e22a1
1
Parent(s): 8380712
Add readable redacted trace summaries
Browse filesCo-Authored-By: Codex <codex@openai.com>
- README.md +8 -5
- static/index.html +1 -1
- tests/test_tracing.py +19 -4
- traces/data/trace_samples.jsonl +6 -6
- traces/dataset_card.md +26 -10
- traces/runtime.py +163 -33
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.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 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.
|
| 92 |
-
|
| 93 |
-
|
| 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:
|
| 2 |
-
{"input": "text: FBR
|
| 3 |
-
{"input": "text:
|
| 4 |
-
{"input": "image: Courier-style content with link, urgency, courier signals", "input_category": "courier", "
|
| 5 |
-
{"input": "image: Marketplace-style content with credential signals", "input_category": "marketplace", "
|
| 6 |
-
{"input": "image: Traffic-challan-style content with link, urgency, payment, challan signals", "input_category": "traffic_challan", "
|
|
|
|
| 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`:
|
|
|
|
| 38 |
- `input_category`: deterministic category such as courier, bank, or FBR
|
| 39 |
- `urgency`: deterministic boolean urgency signal
|
| 40 |
-
-
|
| 41 |
-
|
| 42 |
-
-
|
| 43 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 70 |
-
generation itself does not invoke the model.
|
| 71 |
|
| 72 |
## Limitations
|
| 73 |
|
| 74 |
- Regex signals and category detection are approximate.
|
| 75 |
-
-
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
"input_category": category,
|
| 143 |
"urgency": signals["urgency"],
|
| 144 |
-
"
|
| 145 |
-
|
|
|
|
| 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 |
-
"
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
"
|
| 186 |
-
"
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
|
|
@@ -201,9 +295,28 @@ def validate_trace(record: Any) -> list[str]:
|
|
| 201 |
"input",
|
| 202 |
"input_category",
|
| 203 |
"urgency",
|
| 204 |
-
"
|
| 205 |
-
"
|
| 206 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
|
|
|
|
|
|
| 225 |
errors.append("Invalid risk label.")
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|