Commit ·
818c308
1
Parent(s): e4f211a
Simplify image trace classification
Browse filesDerive privacy-safe image categories and tactics from the existing assessment explanation and red flags instead of requesting separate model metadata.
Co-authored-by: Codex <codex@openai.com>
- app.py +1 -48
- tests/test_tracing.py +1 -1
- traces/dataset_card.md +4 -5
- traces/runtime.py +0 -35
app.py
CHANGED
|
@@ -37,33 +37,6 @@ REQUIRED_FIELDS = {
|
|
| 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,11 +67,6 @@ Evidence rules:
|
|
| 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,14 +109,8 @@ OUTPUT_SCHEMA: dict[str, Any] = {
|
|
| 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
|
| 152 |
"additionalProperties": False,
|
| 153 |
}
|
| 154 |
|
|
@@ -211,15 +173,6 @@ def normalize_assessment(value: Any) -> dict[str, Any]:
|
|
| 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.")
|
|
|
|
| 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 |
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 |
"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 |
else ""
|
| 174 |
),
|
| 175 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
for field in ("simple_explanation",):
|
| 177 |
if not result[field]:
|
| 178 |
raise ValueError(f"{field} must not be empty.")
|
tests/test_tracing.py
CHANGED
|
@@ -195,7 +195,7 @@ class TraceTests(unittest.TestCase):
|
|
| 195 |
self.assertNotIn(assessment["red_flags"][0], serialized)
|
| 196 |
self.assertNotIn("PRIVATE_IMAGE_BYTES", serialized)
|
| 197 |
|
| 198 |
-
def
|
| 199 |
record = trace_runtime.build_trace_record(
|
| 200 |
text="",
|
| 201 |
image_data_url="data:image/png;base64,PRIVATE_IMAGE_BYTES",
|
|
|
|
| 195 |
self.assertNotIn(assessment["red_flags"][0], serialized)
|
| 196 |
self.assertNotIn("PRIVATE_IMAGE_BYTES", serialized)
|
| 197 |
|
| 198 |
+
def test_image_trace_uses_result_summary_not_extra_metadata(self) -> None:
|
| 199 |
record = trace_runtime.build_trace_record(
|
| 200 |
text="",
|
| 201 |
image_data_url="data:image/png;base64,PRIVATE_IMAGE_BYTES",
|
traces/dataset_card.md
CHANGED
|
@@ -31,11 +31,10 @@ The application uses a Modal-hosted Qwen model for normal assessments. Creating
|
|
| 31 |
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
|
| 35 |
-
|
| 36 |
-
and
|
| 37 |
-
|
| 38 |
-
deterministic English/Urdu evidence rules when they disagree.
|
| 39 |
|
| 40 |
## Fields
|
| 41 |
|
|
|
|
| 31 |
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 are inspected transiently for this mapping, but their
|
| 35 |
+
text is not stored. The trace mapper predicts the privacy-safe image category
|
| 36 |
+
and tactics directly from that result summary using deterministic English/Urdu
|
| 37 |
+
evidence rules.
|
|
|
|
| 38 |
|
| 39 |
## Fields
|
| 40 |
|
traces/runtime.py
CHANGED
|
@@ -377,23 +377,6 @@ def assessment_evidence(assessment: dict[str, Any] | None) -> str:
|
|
| 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,11 +390,6 @@ def build_input_profile(
|
|
| 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,19 +397,6 @@ def build_input_profile(
|
|
| 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"
|
|
|
|
| 377 |
return " ".join(values)[:4000]
|
| 378 |
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
def build_input_profile(
|
| 381 |
text: str,
|
| 382 |
image_data_url: str,
|
|
|
|
| 390 |
input_type = "image"
|
| 391 |
else:
|
| 392 |
input_type = "text"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
classification_text = text
|
| 394 |
if input_type == "image" and not example_id:
|
| 395 |
classification_text = " ".join(
|
|
|
|
| 397 |
)
|
| 398 |
signals = detect_signals(classification_text, example_id)
|
| 399 |
category = detect_category(classification_text, signals, example_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
tactics = [name for name, enabled in signals.items() if enabled]
|
| 401 |
if input_type == "image" and not assessment and not example_id:
|
| 402 |
input_description = "image: Assessment unavailable"
|