Commit ·
e4f211a
1
Parent(s): 0661c20
Improve privacy-safe trace quality
Browse filesStrengthen 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 +6 -0
- app.py +48 -1
- tests/test_tracing.py +80 -0
- traces/dataset_card.md +27 -1
- traces/runtime.py +265 -61
- traces/scripts/analyze_trace_dataset.py +160 -0
- traces/scripts/validate_traces.py +6 -0
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 |
-
|
| 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
|
| 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 |
-
|
| 375 |
-
errors.append("Input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
if not isinstance(record.get("urgency"), bool):
|
| 377 |
errors.append("Urgency must be boolean.")
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|