QuantumTransformer commited on
Commit
2e07357
·
verified ·
1 Parent(s): 8b1bf88

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
2
+ FROM ${BASE_IMAGE} AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ ARG BUILD_MODE=in-repo
7
+
8
+ COPY . /app/env
9
+
10
+ WORKDIR /app/env
11
+
12
+ RUN if ! command -v uv >/dev/null 2>&1; then \
13
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
14
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
15
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
16
+ fi
17
+
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ git \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ RUN --mount=type=cache,target=/root/.cache/uv \
23
+ if [ -f uv.lock ]; then \
24
+ uv sync --frozen --no-install-project --no-editable; \
25
+ else \
26
+ uv sync --no-install-project --no-editable; \
27
+ fi
28
+
29
+ RUN --mount=type=cache,target=/root/.cache/uv \
30
+ if [ -f uv.lock ]; then \
31
+ uv sync --frozen --no-editable; \
32
+ else \
33
+ uv sync --no-editable; \
34
+ fi
35
+
36
+ FROM ${BASE_IMAGE}
37
+
38
+ WORKDIR /app
39
+
40
+ COPY --from=builder /app/env/.venv /app/.venv
41
+ COPY --from=builder /app/env /app/env
42
+
43
+ ENV PATH="/app/.venv/bin:$PATH"
44
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
45
+
46
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
47
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
48
+
49
+ ENV ENABLE_WEB_INTERFACE=true
50
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,12 +1,273 @@
1
- ---
2
- title: AdArena
3
- emoji: 🏃
4
- colorFrom: pink
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- license: bsd-3-clause
9
- short_description: Train agents to catch fraud
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Ad Fraud Investigation Environment
3
+ emoji: "\U0001F575\uFE0F"
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ - ad-fraud
13
+ - reinforcement-learning
14
+ ---
15
+
16
+ # Ad Fraud Investigation Environment
17
+
18
+ An OpenEnv environment that simulates ad fraud review - a real-world task where AI agents investigate queues of advertisements, uncover fraud signals, and render verdicts under budget constraints.
19
+
20
+ Ad fraud costs the digital advertising industry over **$100 billion annually**. Platforms like Meta process billions of ads daily and ban advertisers only at high confidence thresholds. Unlike simple classification, real ad review is a **sequential decision-making** problem: a reviewer starts with limited surface-level signals, actively chooses what to investigate within a constrained budget, and must decide when enough evidence exists to commit to a verdict. This environment captures that workflow and provides a training ground for agents to learn it.
21
+
22
+ ## Quick Start
23
+
24
+ ### Install
25
+
26
+ ```bash
27
+ pip install -e .
28
+ ```
29
+
30
+ ### Run the server
31
+
32
+ ```bash
33
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
34
+ ```
35
+
36
+ ### Use the client
37
+
38
+ ```python
39
+ from ad_fraud_env import AdFraudEnv, AdReviewAction
40
+
41
+ with AdFraudEnv(base_url="http://localhost:8000").sync() as env:
42
+ result = env.reset(seed=42, task_id="task_1")
43
+ print(result.observation.queue_summary)
44
+
45
+ # Investigate an ad
46
+ result = env.step(AdReviewAction(
47
+ action_type="investigate",
48
+ ad_id="ad_001",
49
+ investigation_target="landing_page",
50
+ ))
51
+ print(result.observation.feedback)
52
+
53
+ # Render a verdict
54
+ result = env.step(AdReviewAction(
55
+ action_type="verdict",
56
+ ad_id="ad_001",
57
+ verdict="reject",
58
+ confidence=0.9,
59
+ ))
60
+ print(f"Reward: {result.reward}, Done: {result.done}")
61
+ ```
62
+
63
+ ### Run with Docker
64
+
65
+ ```bash
66
+ docker build -t ad-fraud-env .
67
+ docker run -p 8000:8000 ad-fraud-env
68
+ ```
69
+
70
+ ## Environment Design
71
+
72
+ ### Episode flow
73
+
74
+ Each episode is a review session. The agent receives a queue of ads and must process them within a limited action budget:
75
+
76
+ ```
77
+ reset(task_id, seed)
78
+ |
79
+ v
80
+ +----------------------------------+<----------------------+
81
+ | Observe queue + first ad info | |
82
+ +------------------+---------------+ |
83
+ | |
84
+ v |
85
+ +-------------+ +------------------+ |
86
+ | investigate |---->| Reveal one signal |----------+
87
+ +-------------+ | (costs 1 budget) |
88
+ | +------------------+
89
+ v
90
+ +-------------+ +------------------+
91
+ | verdict |---->| approve / reject |----------+
92
+ +-------------+ | / escalate | |
93
+ | +------------------+ |
94
+ v |
95
+ +--------------+ +------------------+ |
96
+ | link_accounts|---->| Flag fraud ring |----------+
97
+ +--------------+ | (Task 3 only) |
98
+ | +------------------+
99
+ v
100
+ Budget exhausted or all ads reviewed -> episode ends
101
+ ```
102
+
103
+ ### Tasks
104
+
105
+ Three tasks with increasing difficulty test different capabilities:
106
+
107
+ | Task | Name | Ads | Budget | Composition | Challenge |
108
+ |---|---|---:|---:|---|---|
109
+ | 1 | Basic Ad Triage | 5 | 25 | 2 legit, 3 obvious fraud | Learn the investigate -> verdict loop |
110
+ | 2 | Sophisticated Fraud | 12 | 30 | 5 legit, 5 sophisticated scams, 2 gray-area | Triage under budget pressure (~2.5 actions/ad) |
111
+ | 3 | Fraud Network Detection | 20 | 35 | 6 legit, 10 fraud (3 hidden rings), 4 gray-area | Cross-ad reasoning to detect coordinated networks (~1.75 actions/ad) |
112
+
113
+ Task 3 introduces **fraud rings** - clusters of 3-5 ads controlled by the same actor, using varied topologies (cliques, chains, hub-and-spoke). Individual ring members look borderline; the fraud signal is only visible by cross-referencing investigation data across ads (shared payment IDs, matching template hashes, overlapping targeting fingerprints).
114
+
115
+ ### Action Space
116
+
117
+ Actions are JSON objects. Three types:
118
+
119
+ **`investigate`** - spend one budget point to reveal a signal about an ad.
120
+
121
+ ```json
122
+ {
123
+ "action_type": "investigate",
124
+ "ad_id": "ad_001",
125
+ "investigation_target": "landing_page"
126
+ }
127
+ ```
128
+
129
+ Each ad has six investigation dimensions:
130
+
131
+ | Target | What it reveals |
132
+ |---|---|
133
+ | `advertiser_history` | Account age, spend history, violation record, verification status |
134
+ | `landing_page` | Domain age, SSL, registrar, redirect chains, scam template similarity |
135
+ | `payment_method` | Payment type, chargeback history, cross-account velocity |
136
+ | `targeting_overlap` | Targeting fingerprint, audience overlap percentages |
137
+ | `creative_similarity` | Template hash, image dimensions, scam template similarity score |
138
+ | `campaign_structure` | Objective, bid strategy, budget/age ratio, placement distribution |
139
+
140
+ **`verdict`** - render a final decision on an ad.
141
+
142
+ ```json
143
+ {
144
+ "action_type": "verdict",
145
+ "ad_id": "ad_001",
146
+ "verdict": "reject",
147
+ "confidence": 0.9
148
+ }
149
+ ```
150
+
151
+ `verdict` options: `approve`, `reject`, `escalate`. `confidence`: 0.0-1.0.
152
+
153
+ **`link_accounts`** - flag two ads as part of the same fraud network (Task 3).
154
+
155
+ ```json
156
+ {
157
+ "action_type": "link_accounts",
158
+ "ad_id": "ad_003",
159
+ "linked_ad_id": "ad_007",
160
+ "link_reason": "shared payment ID pmt_ring_48231 and matching template hash"
161
+ }
162
+ ```
163
+
164
+ ### Observation Space
165
+
166
+ Observations are text-heavy by design so LLM agents can reason naturally:
167
+
168
+ | Field | Type | Description |
169
+ |---|---|---|
170
+ | `queue_summary` | `str` | Task name, total/reviewed/pending counts, budget remaining |
171
+ | `current_ad_info` | `str` | Ad copy, category, targeting, risk signals for the focused ad |
172
+ | `investigation_findings` | `str` | Accumulated findings from all investigations so far |
173
+ | `verdict_history_summary` | `str` | Verdicts rendered so far |
174
+ | `feedback` | `str` | Natural language feedback on the last action |
175
+ | `available_ads` | `list[str]` | Ad IDs still pending review |
176
+ | `queue_status` | `dict` | Structured status for programmatic access |
177
+ | `done` | `bool` | Whether the episode is complete |
178
+ | `reward` | `float` | Step reward |
179
+
180
+ ## Reward Design
181
+
182
+ | Action | Reward | Rationale |
183
+ |---|---:|---|
184
+ | Investigation | -0.02 | Simulates time/latency cost |
185
+ | Correct rejection (fraud -> reject) | +0.30 to +0.40 | Scaled by fraud severity |
186
+ | Correct approval (legit -> approve) | +0.10 | Revenue preserved |
187
+ | Correct escalation | +0.15 | Appropriate caution |
188
+ | False positive (legit -> reject) | -0.35 | Lost advertiser revenue |
189
+ | False negative (fraud -> approve) | -0.50 | Worst outcome - fraud goes live |
190
+ | Escalate (when wrong) | -0.05 | Human reviewer cost |
191
+ | Correct network link | +0.40 | High-value coordinated fraud detection |
192
+ | Incorrect network link | -0.25 | False accusation cost |
193
+
194
+ Unreviewed ads are auto-approved at episode end - missed fraud incurs the full -0.50 false-negative penalty.
195
+
196
+ ## Grading & Scoring
197
+
198
+ Each task has a dedicated grader that produces a normalized **0.0-1.0 score**. Raw reward is normalized between theoretical worst-case (every decision wrong + full budget wasted) and best-case (every decision correct + efficient budget use).
199
+
200
+ | Component | Task 1 | Task 2 | Task 3 |
201
+ |---|:---:|:---:|:---:|
202
+ | Verdict accuracy | Yes | Yes | Yes |
203
+ | Budget efficiency bonus | Yes | Yes | Yes |
204
+ | Calibration bonus | - | Yes | Yes |
205
+ | Network detection (edge coverage) | - | - | Yes |
206
+ | Investigation coverage bonus | - | - | Yes |
207
+
208
+ **Calibration bonus** rewards agents whose stated confidence correlates with actual accuracy - high confidence on correct verdicts and low confidence on uncertain ones.
209
+
210
+ **Network detection** uses edge coverage: what fraction of ground-truth fraud ring connections did the agent discover via `link_accounts`?
211
+
212
+ **Coverage bonus** rewards breadth over depth - agents that review more ads (rather than deep-diving a single one) score higher on Task 3.
213
+
214
+ ## Baseline Scores
215
+
216
+ Generated with `seed=42` using `meta-llama/Llama-3.1-8B-Instruct`. Reproducible via `python inference.py`.
217
+
218
+ | Task | Score | Steps | Verdicts |
219
+ |---|---:|---:|---:|
220
+ | Task 1 (Easy) | 0.953 | 10 | 5/5 |
221
+ | Task 2 (Medium) | 0.882 | 23 | 12/12 |
222
+ | Task 3 (Hard) | 0.415 | 35 | 20/20 |
223
+
224
+ The sharp drop on Task 3 reflects the difficulty of cross-ad reasoning under tight budget - the baseline agent investigates and renders verdicts well but struggles to detect coordinated fraud rings.
225
+
226
+ ## Project Structure
227
+
228
+ ```
229
+ ad_fraud_env/
230
+ +-- __init__.py # Package exports
231
+ +-- client.py # WebSocket client (extends EnvClient)
232
+ +-- models.py # Action, Observation, State types
233
+ +-- inference.py # Baseline LLM agent with mandatory stdout logging
234
+ +-- openenv.yaml # OpenEnv manifest
235
+ +-- pyproject.toml # Dependencies and package config
236
+ +-- Dockerfile # Multi-stage Docker build
237
+ +-- baseline_scores.json # Cached baseline results
238
+ +-- data/
239
+ | +-- ad_generator.py # Episode generation, task configs, campaign profiles
240
+ | +-- advertiser_profiles.py # Synthetic advertiser history
241
+ | +-- fraud_patterns.py # Fraud + legit ad templates (easy/medium/hard)
242
+ | +-- landing_pages.py # Simulated landing page investigation data
243
+ | +-- network_generator.py # Fraud ring topologies via networkx
244
+ +-- graders/
245
+ | +-- base_grader.py # Shared normalization and reward logic
246
+ | +-- task1_grader.py # Verdict accuracy only
247
+ | +-- task2_grader.py # + calibration bonus
248
+ | +-- task3_grader.py # + network detection + coverage bonus
249
+ +-- server/
250
+ | +-- app.py # FastAPI app with /tasks, /baseline, /grader endpoints
251
+ | +-- environment.py # Core environment (reset/step/state)
252
+ | +-- requirements.txt # Server dependencies
253
+ +-- tests/
254
+ +-- test_data_generation.py # Determinism, cross-ref checks, decoy validation
255
+ +-- test_environment.py # Step logic, state tracking, anti-exploit
256
+ +-- test_graders.py # Score ranges, calibration, network scoring
257
+ ```
258
+
259
+ ## API Endpoints
260
+
261
+ | Endpoint | Method | Description |
262
+ |---|---|---|
263
+ | `/health` | GET | Health check |
264
+ | `/schema` | GET | Action/Observation JSON schemas |
265
+ | `/ws` | WS | WebSocket for `step()` / `reset()` / `state()` |
266
+ | `/tasks` | GET | Task list with configs and action schema |
267
+ | `/baseline` | GET | Baseline scores (cached or live) |
268
+ | `/grader` | GET | Last episode's grader result |
269
+ | `/web` | GET | Auto-generated Gradio UI |
270
+
271
+ ## License
272
+
273
+ BSD 3-Clause License
__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ad Fraud Investigation Environment for OpenEnv.
3
+
4
+ A real-world simulation where an AI agent investigates a queue of ads
5
+ for fraud, making sequential investigation and verdict decisions under
6
+ a limited action budget.
7
+
8
+ Example:
9
+ >>> from ad_fraud_env import AdFraudEnv, AdReviewAction
10
+ >>>
11
+ >>> with AdFraudEnv(base_url="http://localhost:8000").sync() as env:
12
+ ... result = env.reset(seed=42, task_id="task_1")
13
+ ... result = env.step(AdReviewAction(
14
+ ... action_type="investigate",
15
+ ... ad_id="ad_001",
16
+ ... investigation_target="landing_page",
17
+ ... ))
18
+ ... print(result.observation.feedback)
19
+ """
20
+
21
+ from .client import AdFraudEnv
22
+ from .models import AdFraudState, AdReviewAction, AdReviewObservation
23
+
24
+ __all__ = [
25
+ "AdFraudEnv",
26
+ "AdReviewAction",
27
+ "AdReviewObservation",
28
+ "AdFraudState",
29
+ ]
baseline_scores.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "baseline_model": "llama3.1:8b",
3
+ "seed": 42,
4
+ "tasks": {
5
+ "task_1": {
6
+ "task_id": "task_1",
7
+ "score": 0.9603547186637474,
8
+ "steps": 9,
9
+ "verdicts": 5,
10
+ "total_ads": 5
11
+ },
12
+ "task_2": {
13
+ "task_id": "task_2",
14
+ "score": 0.8877459920214088,
15
+ "steps": 24,
16
+ "verdicts": 12,
17
+ "total_ads": 12
18
+ },
19
+ "task_3": {
20
+ "task_id": "task_3",
21
+ "score": 0.4817339313969324,
22
+ "steps": 35,
23
+ "verdicts": 20,
24
+ "total_ads": 20
25
+ }
26
+ }
27
+ }
client.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ad Fraud Investigation Environment Client.
3
+
4
+ Provides programmatic access via WebSocket. Users and evaluators interact
5
+ with the environment using:
6
+
7
+ env = AdFraudEnv.from_hub("your-org/ad-fraud-env")
8
+ # or
9
+ env = AdFraudEnv(base_url="http://localhost:8000")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict
15
+
16
+ from openenv.core.client_types import StepResult
17
+ from openenv.core.env_client import EnvClient
18
+
19
+ from .models import AdFraudState, AdReviewAction, AdReviewObservation
20
+
21
+
22
+ class AdFraudEnv(EnvClient[AdReviewAction, AdReviewObservation, AdFraudState]):
23
+ """
24
+ WebSocket client for the Ad Fraud Investigation Environment.
25
+
26
+ Example:
27
+ >>> with AdFraudEnv(base_url="http://localhost:8000").sync() as env:
28
+ ... result = env.reset(seed=42, task_id="task_1")
29
+ ... print(result.observation.queue_summary)
30
+ ... result = env.step(AdReviewAction(
31
+ ... action_type="investigate",
32
+ ... ad_id="ad_001",
33
+ ... investigation_target="advertiser_history",
34
+ ... ))
35
+ ... print(result.observation.feedback)
36
+ """
37
+
38
+ def _step_payload(self, action: AdReviewAction) -> Dict[str, Any]:
39
+ """Convert action to JSON payload for the WebSocket step message."""
40
+ return action.model_dump(exclude_none=True, exclude={"metadata"})
41
+
42
+ def _parse_result(
43
+ self, payload: Dict[str, Any]
44
+ ) -> StepResult[AdReviewObservation]:
45
+ """Parse server response into a typed StepResult.
46
+
47
+ OpenEnv serializes as {"observation": {...}, "reward": float, "done": bool}
48
+ with reward/done at the top level, excluded from the observation dict.
49
+ """
50
+ obs_data = payload.get("observation", {})
51
+ reward = payload.get("reward", 0.0) or 0.0
52
+ done = payload.get("done", False)
53
+
54
+ observation = AdReviewObservation(
55
+ done=done,
56
+ reward=reward,
57
+ queue_summary=obs_data.get("queue_summary", ""),
58
+ current_ad_info=obs_data.get("current_ad_info", ""),
59
+ investigation_findings=obs_data.get("investigation_findings", ""),
60
+ verdict_history_summary=obs_data.get("verdict_history_summary", ""),
61
+ feedback=obs_data.get("feedback", ""),
62
+ available_ads=obs_data.get("available_ads", []),
63
+ queue_status=obs_data.get("queue_status", {}),
64
+ metadata=obs_data.get("metadata", {}),
65
+ )
66
+
67
+ return StepResult(
68
+ observation=observation,
69
+ reward=reward,
70
+ done=done,
71
+ )
72
+
73
+ def _parse_state(self, payload: Dict[str, Any]) -> AdFraudState:
74
+ """Parse server state response into AdFraudState."""
75
+ return AdFraudState(
76
+ episode_id=payload.get("episode_id"),
77
+ step_count=payload.get("step_count", 0),
78
+ task_id=payload.get("task_id", ""),
79
+ total_ads=payload.get("total_ads", 0),
80
+ reviewed_count=payload.get("reviewed_count", 0),
81
+ remaining_budget=payload.get("remaining_budget", 0),
82
+ verdicts=payload.get("verdicts", {}),
83
+ grader_score=payload.get("grader_score"),
84
+ )
convo_logging/task_1_conversation.md ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Episode Log — task_1
2
+
3
+
4
+ ## Step 0
5
+
6
+ ### Observation (sent to LLM)
7
+ ```
8
+ Queue: Task: Basic Ad Triage (easy)
9
+ Total ads: 5 | Reviewed: 0 | Pending: 5 | Steps remaining: 25/25 | Investigation budget: 25 | Step: 0
10
+
11
+ Current Ad: === Ad in Focus: ad_001 ===
12
+ Category: local_service
13
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
14
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
15
+ Initial risk signals: None
16
+ Advertiser country: Germany
17
+ Account age: 1132 days
18
+ Investigations completed: None yet
19
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
20
+
21
+ Feedback: Episode started. Review the ad queue and begin your investigation.
22
+
23
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005
24
+ ```
25
+
26
+ ### LLM Response
27
+ ```json
28
+ {
29
+ "action_type": "investigate",
30
+ "ad_id": "ad_001",
31
+ "investigation_target": "targeting_overlap"
32
+ }
33
+ ```
34
+
35
+ ### Parsed Action
36
+ ```json
37
+ {
38
+ "action_type": "investigate",
39
+ "ad_id": "ad_001",
40
+ "investigation_target": "targeting_overlap"
41
+ }
42
+ ```
43
+
44
+ ### Environment Response
45
+ - **Reward:** `-0.02`
46
+ - **Done:** `False`
47
+
48
+ - **Feedback:** Investigation complete: targeting_overlap for ad_001.
49
+ --- Findings ---
50
+ Targeting Analysis for ad_001:
51
+ Declared targeting: Adults 18-55, Brooklyn, interests: food delivery
52
+ Targeting fingerprint: seg_20328
53
+ Audience overlap with platform average for category: 27%
54
+ Geographic regions targeted: 2
55
+
56
+
57
+ ## Step 1
58
+
59
+ ### Observation (sent to LLM)
60
+ ```
61
+ Queue: Task: Basic Ad Triage (easy)
62
+ Total ads: 5 | Reviewed: 0 | Pending: 5 | Steps remaining: 24/25 | Investigation budget: 24 | Step: 1
63
+
64
+ Current Ad: === Ad in Focus: ad_001 ===
65
+ Category: local_service
66
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
67
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
68
+ Initial risk signals: None
69
+ Advertiser country: Germany
70
+ Account age: 1132 days
71
+ Investigations completed: targeting_overlap
72
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
73
+
74
+ Feedback: Investigation complete: targeting_overlap for ad_001.
75
+ --- Findings ---
76
+ Targeting Analysis for ad_001:
77
+ Declared targeting: Adults 18-55, Brooklyn, interests: food delivery
78
+ Targeting fingerprint: seg_20328
79
+ Audience overlap with platform average for category: 27%
80
+ Geographic regions targeted: 2
81
+
82
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005
83
+
84
+ Findings:
85
+
86
+ [ad_001 / targeting_overlap]
87
+ Targeting Analysis for ad_001:
88
+ Declared targeting: Adults 18-55, Brooklyn, interests: food delivery
89
+ Targeting fingerprint: seg_20328
90
+ Audience overlap with platform average for category: 27%
91
+ Geographic regions targeted: 2
92
+ ```
93
+
94
+ ### LLM Response
95
+ ```json
96
+ {
97
+ "action_type": "verdict",
98
+ "ad_id": "ad_001",
99
+ "verdict": "approve",
100
+ "confidence": 0.9
101
+ }
102
+ ```
103
+
104
+ ### Parsed Action
105
+ ```json
106
+ {
107
+ "action_type": "verdict",
108
+ "ad_id": "ad_001",
109
+ "verdict": "approve",
110
+ "confidence": 0.9
111
+ }
112
+ ```
113
+
114
+ ### Environment Response
115
+ - **Reward:** `+0.10`
116
+ - **Done:** `False`
117
+
118
+ - **Feedback:** Verdict recorded for ad_001: approve (confidence: 0.90). 4 ad(s) remaining in queue.
119
+
120
+
121
+ ## Step 2
122
+
123
+ ### Observation (sent to LLM)
124
+ ```
125
+ Queue: Task: Basic Ad Triage (easy)
126
+ Total ads: 5 | Reviewed: 1 | Pending: 4 | Steps remaining: 23/25 | Investigation budget: 24 | Step: 2
127
+
128
+ Current Ad: === Ad in Focus: ad_002 ===
129
+ Category: counterfeit_goods
130
+ Ad copy: "Rolex watches starting at $49.99! 100% genuine! Free worldwide shipping! LuxWatchDeals.net"
131
+ Targeting: Men 25-55, interests: watches, luxury
132
+ Initial risk signals: impossibly low prices for luxury goods, unfamiliar domain, claims of authenticity with extreme discounts
133
+ Advertiser country: Estonia
134
+ Account age: 6 days
135
+ Flag: New account (< 30 days)
136
+ Investigations completed: None yet
137
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
138
+
139
+ Feedback: Verdict recorded for ad_001: approve (confidence: 0.90). 4 ad(s) remaining in queue.
140
+
141
+ Available ads: ad_002, ad_003, ad_004, ad_005
142
+
143
+ Verdicts: Reviewed 1 ad(s): 1 approve.
144
+ approve: ad_001
145
+
146
+ Findings:
147
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
148
+ ```
149
+
150
+ ### LLM Response
151
+ ```json
152
+ {
153
+ "action_type": "investigate",
154
+ "ad_id": "ad_002",
155
+ "investigation_target": "landing_page"
156
+ }
157
+ ```
158
+
159
+ ### Parsed Action
160
+ ```json
161
+ {
162
+ "action_type": "investigate",
163
+ "ad_id": "ad_002",
164
+ "investigation_target": "landing_page"
165
+ }
166
+ ```
167
+
168
+ ### Environment Response
169
+ - **Reward:** `-0.02`
170
+ - **Done:** `False`
171
+
172
+ - **Feedback:** Investigation complete: landing_page for ad_002.
173
+ --- Findings ---
174
+ Domain: elite2813.buzz
175
+ Domain age: 35 days
176
+ SSL: NO SSL / expired certificate
177
+ Registrar: Epik
178
+ WHOIS privacy: Disabled (registrant info public)
179
+ Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site
180
+ Similarity to known scam templates: 71%
181
+ Key claims on page:
182
+ - Authentic products
183
+ - Direct from manufacturer
184
+ - 90% below retail price
185
+ Suspicious elements:
186
+ - Product images appear stolen from official brand site
187
+ - Terms of service link returns 404
188
+ Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
189
+
190
+
191
+ ## Step 3
192
+
193
+ ### Observation (sent to LLM)
194
+ ```
195
+ Queue: Task: Basic Ad Triage (easy)
196
+ Total ads: 5 | Reviewed: 1 | Pending: 4 | Steps remaining: 22/25 | Investigation budget: 23 | Step: 3
197
+
198
+ Current Ad: === Ad in Focus: ad_002 ===
199
+ Category: counterfeit_goods
200
+ Ad copy: "Rolex watches starting at $49.99! 100% genuine! Free worldwide shipping! LuxWatchDeals.net"
201
+ Targeting: Men 25-55, interests: watches, luxury
202
+ Initial risk signals: impossibly low prices for luxury goods, unfamiliar domain, claims of authenticity with extreme discounts
203
+ Advertiser country: Estonia
204
+ Account age: 6 days
205
+ Flag: New account (< 30 days)
206
+ Investigations completed: landing_page
207
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
208
+
209
+ Feedback: Investigation complete: landing_page for ad_002.
210
+ --- Findings ---
211
+ Domain: elite2813.buzz
212
+ Domain age: 35 days
213
+ SSL: NO SSL / expired certificate
214
+ Registrar: Epik
215
+ WHOIS privacy: Disabled (registrant info public)
216
+ Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site
217
+ Similarity to known scam templates: 71%
218
+ Key claims on page:
219
+ - Authentic products
220
+ - Direct from manufacturer
221
+ - 90% below retail price
222
+ Suspicious elements:
223
+ - Product images appear stolen from official brand site
224
+ - Terms of service link returns 404
225
+ Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
226
+
227
+ Available ads: ad_002, ad_003, ad_004, ad_005
228
+
229
+ Verdicts: Reviewed 1 ad(s): 1 approve.
230
+ approve: ad_001
231
+
232
+ Findings:
233
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
234
+
235
+ [ad_002 / landing_page]
236
+ Domain: elite2813.buzz
237
+ Domain age: 35 days
238
+ SSL: NO SSL / expired certificate
239
+ Registrar: Epik
240
+ WHOIS privacy: Disabled (registrant info public)
241
+ Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site
242
+ Similarity to known scam templates: 71%
243
+ Key claims on page:
244
+ - Authentic products
245
+ - Direct from manufacturer
246
+ - 90% below retail price
247
+ Suspicious elements:
248
+ - Product images appear stolen from official brand site
249
+ - Terms of service link returns 404
250
+ Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
251
+ ```
252
+
253
+ ### LLM Response
254
+ ```json
255
+ {
256
+ "action_type": "verdict",
257
+ "ad_id": "ad_002",
258
+ "verdict": "reject",
259
+ "confidence": 0.95
260
+ }
261
+ ```
262
+
263
+ ### Parsed Action
264
+ ```json
265
+ {
266
+ "action_type": "verdict",
267
+ "ad_id": "ad_002",
268
+ "verdict": "reject",
269
+ "confidence": 0.95
270
+ }
271
+ ```
272
+
273
+ ### Environment Response
274
+ - **Reward:** `+0.38`
275
+ - **Done:** `False`
276
+
277
+ - **Feedback:** Verdict recorded for ad_002: reject (confidence: 0.95). 3 ad(s) remaining in queue.
278
+
279
+
280
+ ## Step 4
281
+
282
+ ### Observation (sent to LLM)
283
+ ```
284
+ Queue: Task: Basic Ad Triage (easy)
285
+ Total ads: 5 | Reviewed: 2 | Pending: 3 | Steps remaining: 21/25 | Investigation budget: 23 | Step: 4
286
+
287
+ Current Ad: === Ad in Focus: ad_003 ===
288
+ Category: fake_giveaway
289
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
290
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
291
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
292
+ Advertiser country: Estonia
293
+ Account age: 19 days
294
+ Flag: New account (< 30 days)
295
+ Investigations completed: None yet
296
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
297
+
298
+ Feedback: Verdict recorded for ad_002: reject (confidence: 0.95). 3 ad(s) remaining in queue.
299
+
300
+ Available ads: ad_003, ad_004, ad_005
301
+
302
+ Verdicts: Reviewed 2 ad(s): 1 approve, 1 reject.
303
+ reject: ad_002
304
+ approve: ad_001
305
+
306
+ Findings:
307
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
308
+ [ad_002 / landing_page] Domain: elite2813.buzz | Domain age: 35 days | SSL: NO SSL / expired certificate | Registrar: Epik | WHOIS privacy: Disabled (registrant info public) | Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site | Similarity to known scam templates: 71% | Authentic products | Direct from manufacturer | 90% below retail price | Product images appear stolen from official brand site | Terms of service link returns 404 | Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
309
+ ```
310
+
311
+ ### LLM Response
312
+ ```json
313
+ {
314
+ "action_type": "investigate",
315
+ "ad_id": "ad_003",
316
+ "investigation_target": "landing_page"
317
+ }
318
+ ```
319
+
320
+ ### Parsed Action
321
+ ```json
322
+ {
323
+ "action_type": "investigate",
324
+ "ad_id": "ad_003",
325
+ "investigation_target": "landing_page"
326
+ }
327
+ ```
328
+
329
+ ### Environment Response
330
+ - **Reward:** `-0.02`
331
+ - **Done:** `False`
332
+
333
+ - **Feedback:** Investigation complete: landing_page for ad_003.
334
+ --- Findings ---
335
+ Domain: shop2631.buzz
336
+ Domain age: 51 days
337
+ SSL: NO SSL / expired certificate
338
+ Registrar: Openprovider
339
+ WHOIS privacy: Disabled (registrant info public)
340
+ Redirect chain: shop2631.buzz -> redirect611.top -> redirect542.top -> redirect995.xyz -> redirect882.click -> final-697.site
341
+ Similarity to known scam templates: 53%
342
+ Key claims on page:
343
+ - 100% free, no purchase necessary
344
+ - Guaranteed winner
345
+ - Act now — expires in 2 hours
346
+ Suspicious elements:
347
+ - No physical address listed
348
+ - No contact phone number
349
+ Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
350
+
351
+
352
+ ## Step 5
353
+
354
+ ### Observation (sent to LLM)
355
+ ```
356
+ Queue: Task: Basic Ad Triage (easy)
357
+ Total ads: 5 | Reviewed: 2 | Pending: 3 | Steps remaining: 20/25 | Investigation budget: 22 | Step: 5
358
+
359
+ Current Ad: === Ad in Focus: ad_003 ===
360
+ Category: fake_giveaway
361
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
362
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
363
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
364
+ Advertiser country: Estonia
365
+ Account age: 19 days
366
+ Flag: New account (< 30 days)
367
+ Investigations completed: landing_page
368
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
369
+
370
+ Feedback: Investigation complete: landing_page for ad_003.
371
+ --- Findings ---
372
+ Domain: shop2631.buzz
373
+ Domain age: 51 days
374
+ SSL: NO SSL / expired certificate
375
+ Registrar: Openprovider
376
+ WHOIS privacy: Disabled (registrant info public)
377
+ Redirect chain: shop2631.buzz -> redirect611.top -> redirect542.top -> redirect995.xyz -> redirect882.click -> final-697.site
378
+ Similarity to known scam templates: 53%
379
+ Key claims on page:
380
+ - 100% free, no purchase necessary
381
+ - Guaranteed winner
382
+ - Act now — expires in 2 hours
383
+ Suspicious elements:
384
+ - No physical address listed
385
+ - No contact phone number
386
+ Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
387
+
388
+ Available ads: ad_003, ad_004, ad_005
389
+
390
+ Verdicts: Reviewed 2 ad(s): 1 approve, 1 reject.
391
+ reject: ad_002
392
+ approve: ad_001
393
+
394
+ Findings:
395
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
396
+ [ad_002 / landing_page] Domain: elite2813.buzz | Domain age: 35 days | SSL: NO SSL / expired certificate | Registrar: Epik | WHOIS privacy: Disabled (registrant info public) | Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site | Similarity to known scam templates: 71% | Authentic products | Direct from manufacturer | 90% below retail price | Product images appear stolen from official brand site | Terms of service link returns 404 | Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
397
+
398
+ [ad_003 / landing_page]
399
+ Domain: shop2631.buzz
400
+ Domain age: 51 days
401
+ SSL: NO SSL / expired certificate
402
+ Registrar: Openprovider
403
+ WHOIS privacy: Disabled (registrant info public)
404
+ Redirect chain: shop2631.buzz -> redirect611.top -> redirect542.top -> redirect995.xyz -> redirect882.click -> final-697.site
405
+ Similarity to known scam templates: 53%
406
+ Key claims on page:
407
+ - 100% free, no purchase necessary
408
+ - Guaranteed winner
409
+ - Act now — expires in 2 hours
410
+ Suspicious elements:
411
+ - No physical address listed
412
+ - No contact phone number
413
+ Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
414
+ ```
415
+
416
+ ### LLM Response
417
+ ```json
418
+ {
419
+ "action_type": "verdict",
420
+ "ad_id": "ad_003",
421
+ "verdict": "reject",
422
+ "confidence": 0.85
423
+ }
424
+ ```
425
+
426
+ ### Parsed Action
427
+ ```json
428
+ {
429
+ "action_type": "verdict",
430
+ "ad_id": "ad_003",
431
+ "verdict": "reject",
432
+ "confidence": 0.85
433
+ }
434
+ ```
435
+
436
+ ### Environment Response
437
+ - **Reward:** `+0.39`
438
+ - **Done:** `False`
439
+
440
+ - **Feedback:** Verdict recorded for ad_003: reject (confidence: 0.85). 2 ad(s) remaining in queue.
441
+
442
+
443
+ ## Step 6
444
+
445
+ ### Observation (sent to LLM)
446
+ ```
447
+ Queue: Task: Basic Ad Triage (easy)
448
+ Total ads: 5 | Reviewed: 3 | Pending: 2 | Steps remaining: 19/25 | Investigation budget: 22 | Step: 6
449
+
450
+ Current Ad: === Ad in Focus: ad_004 ===
451
+ Category: ecommerce
452
+ Ad copy: "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest."
453
+ Targeting: Women 25-54, interests: home decor, shopping
454
+ Initial risk signals: None
455
+ Advertiser country: Germany
456
+ Account age: 615 days
457
+ Investigations completed: None yet
458
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
459
+
460
+ Feedback: Verdict recorded for ad_003: reject (confidence: 0.85). 2 ad(s) remaining in queue.
461
+
462
+ Available ads: ad_004, ad_005
463
+
464
+ Verdicts: Reviewed 3 ad(s): 1 approve, 2 reject.
465
+ reject: ad_002, ad_003
466
+ approve: ad_001
467
+
468
+ Findings:
469
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
470
+ [ad_002 / landing_page] Domain: elite2813.buzz | Domain age: 35 days | SSL: NO SSL / expired certificate | Registrar: Epik | WHOIS privacy: Disabled (registrant info public) | Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site | Similarity to known scam templates: 71% | Authentic products | Direct from manufacturer | 90% below retail price | Product images appear stolen from official brand site | Terms of service link returns 404 | Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
471
+ [ad_003 / landing_page] Domain: shop2631.buzz | Domain age: 51 days | SSL: NO SSL / expired certificate | Registrar: Openprovider | WHOIS privacy: Disabled (registrant info public) | Redirect chain: shop2631.buzz -> redirect611.top -> redirect542.top -> redirect995.xyz -> redirect882.click -> final-697.site | Similarity to known scam templates: 53% | 100% free, no purchase necessary | Guaranteed winner | Act now — expires in 2 hours | No physical address listed | No contact phone number | Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
472
+ ```
473
+
474
+ ### LLM Response
475
+ ```json
476
+ {
477
+ "action_type": "investigate",
478
+ "ad_id": "ad_004",
479
+ "investigation_target": "landing_page"
480
+ }
481
+ ```
482
+
483
+ ### Parsed Action
484
+ ```json
485
+ {
486
+ "action_type": "investigate",
487
+ "ad_id": "ad_004",
488
+ "investigation_target": "landing_page"
489
+ }
490
+ ```
491
+
492
+ ### Environment Response
493
+ - **Reward:** `-0.02`
494
+ - **Done:** `False`
495
+
496
+ - **Feedback:** Investigation complete: landing_page for ad_004.
497
+ --- Findings ---
498
+ Domain: homenest.com
499
+ Domain age: 3338 days
500
+ SSL: Valid SSL certificate
501
+ Registrar: Google Domains
502
+ WHOIS privacy: Disabled (registrant info public)
503
+ Redirect chain: No redirects (direct)
504
+ Similarity to known scam templates: 4%
505
+ Key claims on page:
506
+ - 14-day free trial
507
+ - Money-back guarantee
508
+ Suspicious elements:
509
+ None detected
510
+ Content summary: SaaS landing page with feature breakdown, pricing tiers, integration docs, and live demo option.
511
+
512
+
513
+ ## Step 7
514
+
515
+ ### Observation (sent to LLM)
516
+ ```
517
+ Queue: Task: Basic Ad Triage (easy)
518
+ Total ads: 5 | Reviewed: 3 | Pending: 2 | Steps remaining: 18/25 | Investigation budget: 21 | Step: 7
519
+
520
+ Current Ad: === Ad in Focus: ad_004 ===
521
+ Category: ecommerce
522
+ Ad copy: "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest."
523
+ Targeting: Women 25-54, interests: home decor, shopping
524
+ Initial risk signals: None
525
+ Advertiser country: Germany
526
+ Account age: 615 days
527
+ Investigations completed: landing_page
528
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
529
+
530
+ Feedback: Investigation complete: landing_page for ad_004.
531
+ --- Findings ---
532
+ Domain: homenest.com
533
+ Domain age: 3338 days
534
+ SSL: Valid SSL certificate
535
+ Registrar: Google Domains
536
+ WHOIS privacy: Disabled (registrant info public)
537
+ Redirect chain: No redirects (direct)
538
+ Similarity to known scam templates: 4%
539
+ Key claims on page:
540
+ - 14-day free trial
541
+ - Money-back guarantee
542
+ Suspicious elements:
543
+ None detected
544
+ Content summary: SaaS landing page with feature breakdown, pricing tiers, integration docs, and live demo option.
545
+
546
+ Available ads: ad_004, ad_005
547
+
548
+ Verdicts: Reviewed 3 ad(s): 1 approve, 2 reject.
549
+ reject: ad_002, ad_003
550
+ approve: ad_001
551
+
552
+ Findings:
553
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
554
+ [ad_002 / landing_page] Domain: elite2813.buzz | Domain age: 35 days | SSL: NO SSL / expired certificate | Registrar: Epik | WHOIS privacy: Disabled (registrant info public) | Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site | Similarity to known scam templates: 71% | Authentic products | Direct from manufacturer | 90% below retail price | Product images appear stolen from official brand site | Terms of service link returns 404 | Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
555
+ [ad_003 / landing_page] Domain: shop2631.buzz | Domain age: 51 days | SSL: NO SSL / expired certificate | Registrar: Openprovider | WHOIS privacy: Disabled (registrant info public) | Redirect chain: shop2631.buzz -> redirect611.top -> redirect542.top -> redirect995.xyz -> redirect882.click -> final-697.site | Similarity to known scam templates: 53% | 100% free, no purchase necessary | Guaranteed winner | Act now — expires in 2 hours | No physical address listed | No contact phone number | Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
556
+
557
+ [ad_004 / landing_page]
558
+ Domain: homenest.com
559
+ Domain age: 3338 days
560
+ SSL: Valid SSL certificate
561
+ Registrar: Google Domains
562
+ WHOIS privacy: Disabled (registrant info public)
563
+ Redirect chain: No redirects (direct)
564
+ Similarity to known scam templates: 4%
565
+ Key claims on page:
566
+ - 14-day free trial
567
+ - Money-back guarantee
568
+ Suspicious elements:
569
+ None detected
570
+ Content summary: SaaS landing page with feature breakdown, pricing tiers, integration docs, and live demo option.
571
+ ```
572
+
573
+ ### LLM Response
574
+ ```json
575
+ {
576
+ "action_type": "verdict",
577
+ "ad_id": "ad_004",
578
+ "verdict": "approve",
579
+ "confidence": 0.95
580
+ }
581
+ ```
582
+
583
+ ### Parsed Action
584
+ ```json
585
+ {
586
+ "action_type": "verdict",
587
+ "ad_id": "ad_004",
588
+ "verdict": "approve",
589
+ "confidence": 0.95
590
+ }
591
+ ```
592
+
593
+ ### Environment Response
594
+ - **Reward:** `+0.10`
595
+ - **Done:** `False`
596
+
597
+ - **Feedback:** Verdict recorded for ad_004: approve (confidence: 0.95). 1 ad(s) remaining in queue.
598
+
599
+
600
+ ## Step 8
601
+
602
+ ### Observation (sent to LLM)
603
+ ```
604
+ Queue: Task: Basic Ad Triage (easy)
605
+ Total ads: 5 | Reviewed: 4 | Pending: 1 | Steps remaining: 17/25 | Investigation budget: 21 | Step: 8
606
+
607
+ Current Ad: === Ad in Focus: ad_005 ===
608
+ Category: fake_giveaway
609
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
610
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
611
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
612
+ Advertiser country: Singapore
613
+ Account age: 40 days
614
+ Investigations completed: None yet
615
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity, campaign_structure
616
+
617
+ Feedback: Verdict recorded for ad_004: approve (confidence: 0.95). 1 ad(s) remaining in queue.
618
+
619
+ Available ads: ad_005
620
+
621
+ Verdicts: Reviewed 4 ad(s): 2 approve, 2 reject.
622
+ reject: ad_002, ad_003
623
+ approve: ad_001, ad_004
624
+
625
+ Findings:
626
+ [ad_001 / targeting_overlap] Declared targeting: Adults 18-55, Brooklyn, interests: food delivery | Targeting fingerprint: seg_20328 | Audience overlap with platform average for category: 27% | Geographic regions targeted: 2
627
+ [ad_002 / landing_page] Domain: elite2813.buzz | Domain age: 35 days | SSL: NO SSL / expired certificate | Registrar: Epik | WHOIS privacy: Disabled (registrant info public) | Redirect chain: elite2813.buzz -> redirect948.xyz -> redirect705.xyz -> redirect225.xyz -> final-333.site | Similarity to known scam templates: 71% | Authentic products | Direct from manufacturer | 90% below retail price | Product images appear stolen from official brand site | Terms of service link returns 404 | Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
628
+ [ad_003 / landing_page] Domain: shop2631.buzz | Domain age: 51 days | SSL: NO SSL / expired certificate | Registrar: Openprovider | WHOIS privacy: Disabled (registrant info public) | Redirect chain: shop2631.buzz -> redirect611.top -> redirect542.top -> redirect995.xyz -> redirect882.click -> final-697.site | Similarity to known scam templates: 53% | 100% free, no purchase necessary | Guaranteed winner | Act now — expires in 2 hours | No physical address listed | No contact phone number | Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
629
+ [ad_004 / landing_page] Domain: homenest.com | Domain age: 3338 days | SSL: Valid SSL certificate | Registrar: Google Domains | WHOIS privacy: Disabled (registrant info public) | Redirect chain: No redirects (direct) | Similarity to known scam templates: 4% | 14-day free trial | Money-back guarantee | None detected | Content summary: SaaS landing page with feature breakdown, pricing tiers, integration docs, and live demo option.
630
+ ```
631
+
632
+ ### LLM Response
633
+ ```json
634
+ {
635
+ "action_type": "verdict",
636
+ "ad_id": "ad_005",
637
+ "verdict": "reject",
638
+ "confidence": 0.9
639
+ }
640
+ ```
641
+
642
+ ### Parsed Action
643
+ ```json
644
+ {
645
+ "action_type": "verdict",
646
+ "ad_id": "ad_005",
647
+ "verdict": "reject",
648
+ "confidence": 0.9
649
+ }
650
+ ```
651
+
652
+ ### Environment Response
653
+ - **Reward:** `+0.39`
654
+ - **Done:** `True`
655
+
656
+ - **Feedback:** Episode complete. Grader score: 0.960/1.000
657
+ Verdicts rendered: 5/5
658
+ Correct decisions: 5/5
659
+ False positives (legit rejected): 0
660
+ False negatives (fraud approved): 0
661
+ Unreviewed ads auto-approved: 0
662
+
663
+
664
+ ---
665
+ ## Result
666
+ **Score: 0.960 | Steps: 9 | Verdicts: 5/5**
convo_logging/task_2_conversation.md ADDED
The diff for this file is too large to render. See raw diff
 
convo_logging/task_3_conversation.md ADDED
The diff for this file is too large to render. See raw diff
 
data/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic data generation for the Ad Fraud Investigation Environment."""
2
+
3
+ from .ad_generator import TASK_CONFIGS, CampaignProfile, generate_episode
4
+ from .advertiser_profiles import generate_advertiser_profile
5
+ from .fraud_patterns import FRAUD_TEMPLATES, LEGIT_TEMPLATES
6
+ from .landing_pages import generate_landing_page
7
+ from .network_generator import generate_fraud_networks
8
+
9
+ __all__ = [
10
+ "generate_episode",
11
+ "generate_advertiser_profile",
12
+ "generate_landing_page",
13
+ "generate_fraud_networks",
14
+ "CampaignProfile",
15
+ "FRAUD_TEMPLATES",
16
+ "LEGIT_TEMPLATES",
17
+ "TASK_CONFIGS",
18
+ ]
data/ad_generator.py ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic ad queue generation.
3
+
4
+ Generates a complete queue of ads for a given task configuration,
5
+ including all pre-generated investigation data. When the agent
6
+ investigates, the environment just reveals pre-computed data.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from .advertiser_profiles import AdvertiserProfile, generate_advertiser_profile
16
+ from .fraud_patterns import FRAUD_TEMPLATES, LEGIT_TEMPLATES, AdTemplate
17
+ from .landing_pages import LandingPageData, generate_landing_page
18
+ from .network_generator import FraudRing, generate_fraud_networks
19
+
20
+ # Decoy pools: values that can appear in both legit and fraud ads,
21
+ # making naive pattern-matching unreliable.
22
+ _DECOY_REGISTRARS = ["NameSilo", "Cloudflare Registrar", "GoDaddy", "Tucows (privacy proxy)"]
23
+ _DECOY_PAYMENT_TYPES = ["credit_card", "prepaid_card", "corporate_card"]
24
+ _COMMON_TARGETING_SEGMENTS = [
25
+ "Adults 25-54, interests: shopping, lifestyle",
26
+ "Adults 18-45, interests: technology, gadgets",
27
+ "Adults 30-55, interests: finance, investing",
28
+ ]
29
+
30
+
31
+ @dataclass
32
+ class TaskConfig:
33
+ task_id: str
34
+ name: str
35
+ difficulty: str
36
+ queue_size: int
37
+ action_budget: int
38
+ n_legit: int
39
+ n_fraud: int
40
+ n_escalate: int
41
+ include_networks: bool
42
+ n_fraud_rings: int
43
+ allowed_difficulties: List[str]
44
+ description: str
45
+
46
+
47
+ TASK_CONFIGS: Dict[str, TaskConfig] = {
48
+ "task_1": TaskConfig(
49
+ task_id="task_1",
50
+ name="Basic Ad Triage",
51
+ difficulty="easy",
52
+ queue_size=5,
53
+ action_budget=25,
54
+ n_legit=2,
55
+ n_fraud=3,
56
+ n_escalate=0,
57
+ include_networks=False,
58
+ n_fraud_rings=0,
59
+ allowed_difficulties=["easy"],
60
+ description=(
61
+ "Learn the investigation loop. Queue of 5 ads with obviously "
62
+ "fraudulent or clearly legitimate signals. Generous budget of 25 "
63
+ "actions (5 per ad)."
64
+ ),
65
+ ),
66
+ "task_2": TaskConfig(
67
+ task_id="task_2",
68
+ name="Sophisticated Fraud Under Budget Pressure",
69
+ difficulty="medium",
70
+ queue_size=12,
71
+ action_budget=30,
72
+ n_legit=5,
73
+ n_fraud=5,
74
+ n_escalate=2,
75
+ include_networks=False,
76
+ n_fraud_rings=0,
77
+ allowed_difficulties=["easy", "medium"],
78
+ description=(
79
+ "Triage under budget constraints. Mix of legit ads, sophisticated "
80
+ "scams, and gray-area cases. 12 ads but only 30 actions (~2.5 per ad). "
81
+ "Agent must prioritize which ads to investigate deeply."
82
+ ),
83
+ ),
84
+ "task_3": TaskConfig(
85
+ task_id="task_3",
86
+ name="Coordinated Fraud Network Detection",
87
+ difficulty="hard",
88
+ queue_size=20,
89
+ action_budget=35,
90
+ n_legit=6,
91
+ n_fraud=10,
92
+ n_escalate=4,
93
+ include_networks=True,
94
+ n_fraud_rings=3,
95
+ allowed_difficulties=["easy", "medium", "hard"],
96
+ description=(
97
+ "Full challenge including coordinated fraud rings. 20 ads with 3 "
98
+ "hidden fraud networks using varied topologies (cliques, chains, "
99
+ "hub-and-spoke). Budget of 35 actions (~1.75 per ad). Ring member "
100
+ "ads look borderline individually — the agent must cross-reference "
101
+ "investigation data across ads to detect shared signals."
102
+ ),
103
+ ),
104
+ }
105
+
106
+
107
+ @dataclass
108
+ class CampaignProfile:
109
+ """Campaign-level metadata associated with an ad."""
110
+ objective: str # e.g. "conversions", "traffic", "awareness", "app_installs"
111
+ bid_strategy: str # e.g. "lowest_cost", "cost_cap", "bid_cap"
112
+ daily_budget_usd: float
113
+ ad_set_count: int
114
+ placements: List[str]
115
+
116
+ def to_investigation_text(self, account_age_days: int) -> str:
117
+ budget_age_ratio = (
118
+ self.daily_budget_usd / max(account_age_days, 1)
119
+ )
120
+ placements_str = ", ".join(self.placements)
121
+
122
+ lines = [
123
+ f"Campaign Objective: {self.objective}",
124
+ f"Bid Strategy: {self.bid_strategy}",
125
+ f"Daily Budget: ${self.daily_budget_usd:,.2f} "
126
+ f"(account is {account_age_days} days old — "
127
+ f"budget/age ratio: ${budget_age_ratio:,.2f}/day)",
128
+ f"Active Ad Sets: {self.ad_set_count}",
129
+ f"Placements: {placements_str}",
130
+ ]
131
+
132
+ warnings = []
133
+ if budget_age_ratio > 50:
134
+ warnings.append(
135
+ "Budget-to-account-age ratio exceeds typical thresholds."
136
+ )
137
+ if self.ad_set_count > 15:
138
+ warnings.append(
139
+ f"High ad set count ({self.ad_set_count}) — "
140
+ "possible policy evasion testing via creative variation."
141
+ )
142
+ if self.objective in ("traffic", "awareness") and self.bid_strategy == "lowest_cost":
143
+ warnings.append(
144
+ f"Optimizing for {self.objective} with lowest-cost bidding "
145
+ "— common in spray-and-pray fraud campaigns."
146
+ )
147
+ if "Audience Network" in self.placements and len(self.placements) <= 2:
148
+ warnings.append(
149
+ "Heavy reliance on Audience Network placement — "
150
+ "higher bot traffic exposure."
151
+ )
152
+
153
+ if warnings:
154
+ for w in warnings:
155
+ lines.append(f" WARNING: {w}")
156
+ else:
157
+ lines.append("Budget and pacing consistent with historical account behavior.")
158
+
159
+ return "\n".join(lines)
160
+
161
+
162
+ @dataclass
163
+ class Ad:
164
+ ad_id: str
165
+ ad_copy: str
166
+ category: str
167
+ targeting_summary: str
168
+ initial_risk_signals: List[str]
169
+ ground_truth_label: str # "fraud", "legit", or "escalate"
170
+ fraud_type: str
171
+ severity: float
172
+ difficulty: str
173
+
174
+
175
+ @dataclass
176
+ class GeneratedEpisode:
177
+ """All pre-generated data for one episode."""
178
+ task_config: TaskConfig
179
+ ads: List[Ad]
180
+ advertiser_profiles: Dict[str, AdvertiserProfile]
181
+ campaign_profiles: Dict[str, CampaignProfile]
182
+ landing_pages: Dict[str, LandingPageData]
183
+ fraud_rings: List[FraudRing]
184
+ ad_to_rings: Dict[str, List[str]]
185
+ investigation_data: Dict[str, Dict[str, str]]
186
+
187
+
188
+ def generate_episode(seed: int, task_id: str = "task_1") -> GeneratedEpisode:
189
+ """Generate a complete episode with all pre-computed investigation data."""
190
+ rng = random.Random(seed)
191
+ config = TASK_CONFIGS[task_id]
192
+
193
+ ads = _generate_ad_queue(rng, config)
194
+
195
+ fraud_ad_ids = [a.ad_id for a in ads if a.ground_truth_label == "fraud"]
196
+
197
+ fraud_rings: List[FraudRing] = []
198
+ ad_to_rings: Dict[str, List[str]] = {}
199
+ ring_shared_payments: Dict[str, str] = {}
200
+
201
+ if config.include_networks and config.n_fraud_rings > 0:
202
+ fraud_rings, ad_to_rings = generate_fraud_networks(
203
+ rng, config.n_fraud_rings, fraud_ad_ids
204
+ )
205
+ for ring in fraud_rings:
206
+ if "payment_method" in ring.shared_signals:
207
+ for ad_id in ring.member_ad_ids:
208
+ ring_shared_payments[ad_id] = ring.shared_signals["payment_method"]
209
+
210
+ advertiser_profiles: Dict[str, AdvertiserProfile] = {}
211
+ campaign_profiles: Dict[str, CampaignProfile] = {}
212
+ landing_pages: Dict[str, LandingPageData] = {}
213
+ investigation_data: Dict[str, Dict[str, str]] = {}
214
+
215
+ ring_campaign_overrides: Dict[str, Dict[str, Any]] = {}
216
+ ring_created_dates: Dict[str, str] = {}
217
+ for ring in fraud_rings:
218
+ shared_objective = rng.choice(["traffic", "awareness"])
219
+ shared_bid = "lowest_cost"
220
+ # Ring members share account creation dates within the same week
221
+ from datetime import date, timedelta
222
+ base_date = date(2026, 4, 6) - timedelta(days=rng.randint(5, 45))
223
+ for ad_id in ring.member_ad_ids:
224
+ ring_campaign_overrides[ad_id] = {
225
+ "objective": shared_objective,
226
+ "bid_strategy": shared_bid,
227
+ }
228
+ offset = timedelta(days=rng.randint(0, 6))
229
+ ring_created_dates[ad_id] = (base_date + offset).isoformat()
230
+
231
+ for ad in ads:
232
+ is_fraud = ad.ground_truth_label in ("fraud", "escalate")
233
+
234
+ profile = generate_advertiser_profile(
235
+ rng, ad.ad_id, is_fraud,
236
+ payment_method_id=ring_shared_payments.get(ad.ad_id),
237
+ ring_created_date=ring_created_dates.get(ad.ad_id),
238
+ )
239
+ advertiser_profiles[ad.ad_id] = profile
240
+
241
+ campaign = _generate_campaign_profile(
242
+ rng, ad, is_fraud,
243
+ ring_overrides=ring_campaign_overrides.get(ad.ad_id),
244
+ )
245
+ campaign_profiles[ad.ad_id] = campaign
246
+
247
+ landing_page_kwargs = {}
248
+ if ad.ad_id in ad_to_rings:
249
+ ring = next(r for r in fraud_rings if ad.ad_id in r.member_ad_ids)
250
+ if "domain_registrar" in ring.shared_signals:
251
+ landing_page_kwargs["registrar_override"] = ring.shared_signals["domain_registrar"]
252
+ elif not is_fraud and rng.random() < 0.25:
253
+ landing_page_kwargs["registrar_override"] = rng.choice(_DECOY_REGISTRARS)
254
+
255
+ lp = generate_landing_page(
256
+ rng, ad.ad_id, is_fraud, ad.fraud_type, **landing_page_kwargs
257
+ )
258
+ landing_pages[ad.ad_id] = lp
259
+
260
+ inv = {}
261
+ inv["advertiser_history"] = profile.to_investigation_text()
262
+ inv["landing_page"] = lp.to_investigation_text()
263
+ inv["payment_method"] = _generate_payment_investigation(rng, profile, ad.ad_id, ad_to_rings, fraud_rings)
264
+ inv["targeting_overlap"] = _generate_targeting_investigation(rng, ad, ads, ad_to_rings, fraud_rings)
265
+ inv["creative_similarity"] = _generate_creative_investigation(rng, ad, ads, ad_to_rings, fraud_rings)
266
+ inv["campaign_structure"] = _generate_campaign_investigation(
267
+ rng, ad, campaign, profile, ad_to_rings, fraud_rings,
268
+ )
269
+ investigation_data[ad.ad_id] = inv
270
+
271
+ return GeneratedEpisode(
272
+ task_config=config,
273
+ ads=ads,
274
+ advertiser_profiles=advertiser_profiles,
275
+ campaign_profiles=campaign_profiles,
276
+ landing_pages=landing_pages,
277
+ fraud_rings=fraud_rings,
278
+ ad_to_rings=ad_to_rings,
279
+ investigation_data=investigation_data,
280
+ )
281
+
282
+
283
+ def _generate_ad_queue(rng: random.Random, config: TaskConfig) -> List[Ad]:
284
+ """Build the ad queue by sampling from templates."""
285
+ ads: List[Ad] = []
286
+ ad_counter = 0
287
+
288
+ legit_templates = [t for t in LEGIT_TEMPLATES]
289
+ fraud_templates = [
290
+ t for t in FRAUD_TEMPLATES
291
+ if t.difficulty in config.allowed_difficulties and t.label == "fraud"
292
+ ]
293
+ escalate_templates = [
294
+ t for t in FRAUD_TEMPLATES
295
+ if t.difficulty in config.allowed_difficulties and t.label == "escalate"
296
+ ]
297
+
298
+ if not escalate_templates:
299
+ escalate_templates = [
300
+ t for t in FRAUD_TEMPLATES if t.label == "escalate"
301
+ ]
302
+
303
+ for _ in range(config.n_legit):
304
+ template = rng.choice(legit_templates)
305
+ idx = rng.randint(0, len(template.ad_copies) - 1)
306
+ ad_counter += 1
307
+ ads.append(Ad(
308
+ ad_id=f"ad_{ad_counter:03d}",
309
+ ad_copy=template.ad_copies[idx],
310
+ category=template.category,
311
+ targeting_summary=template.targeting_hints[idx % len(template.targeting_hints)],
312
+ initial_risk_signals=list(template.risk_signals),
313
+ ground_truth_label=template.label,
314
+ fraud_type=template.fraud_type,
315
+ severity=template.severity,
316
+ difficulty=template.difficulty,
317
+ ))
318
+
319
+ for _ in range(config.n_fraud):
320
+ if fraud_templates:
321
+ template = rng.choice(fraud_templates)
322
+ else:
323
+ template = rng.choice(FRAUD_TEMPLATES)
324
+ idx = rng.randint(0, len(template.ad_copies) - 1)
325
+ ad_counter += 1
326
+ ads.append(Ad(
327
+ ad_id=f"ad_{ad_counter:03d}",
328
+ ad_copy=template.ad_copies[idx],
329
+ category=template.category,
330
+ targeting_summary=template.targeting_hints[idx % len(template.targeting_hints)],
331
+ initial_risk_signals=list(template.risk_signals),
332
+ ground_truth_label="fraud",
333
+ fraud_type=template.fraud_type,
334
+ severity=template.severity,
335
+ difficulty=template.difficulty,
336
+ ))
337
+
338
+ for _ in range(config.n_escalate):
339
+ if escalate_templates:
340
+ template = rng.choice(escalate_templates)
341
+ idx = rng.randint(0, len(template.ad_copies) - 1)
342
+ ad_counter += 1
343
+ ads.append(Ad(
344
+ ad_id=f"ad_{ad_counter:03d}",
345
+ ad_copy=template.ad_copies[idx],
346
+ category=template.category,
347
+ targeting_summary=template.targeting_hints[idx % len(template.targeting_hints)],
348
+ initial_risk_signals=list(template.risk_signals),
349
+ ground_truth_label="escalate",
350
+ fraud_type=template.fraud_type,
351
+ severity=template.severity,
352
+ difficulty=template.difficulty,
353
+ ))
354
+
355
+ rng.shuffle(ads)
356
+
357
+ renumbered = []
358
+ for i, ad in enumerate(ads):
359
+ ad.ad_id = f"ad_{i + 1:03d}"
360
+ renumbered.append(ad)
361
+
362
+ return renumbered
363
+
364
+
365
+ def _generate_payment_investigation(
366
+ rng: random.Random,
367
+ profile: AdvertiserProfile,
368
+ ad_id: str,
369
+ ad_to_rings: Dict[str, List[str]],
370
+ fraud_rings: List[FraudRing],
371
+ ) -> str:
372
+ """Generate payment method investigation text.
373
+
374
+ Ring signals are embedded as raw data values (shared payment IDs) without
375
+ explicitly naming other ads. The agent must cross-reference across ads.
376
+ """
377
+ lines = [
378
+ f"Payment Method Analysis for {ad_id}:",
379
+ f" Method type: {profile.payment_method_type}",
380
+ f" Payment ID: {profile.payment_method_id}",
381
+ ]
382
+
383
+ if profile.payment_method_type in ("prepaid_card", "crypto", "virtual_card"):
384
+ lines.append(f" Note: {profile.payment_method_type} payments have elevated fraud correlation in platform data.")
385
+
386
+ if profile.previous_violations > 0:
387
+ lines.append(f" Chargeback/dispute history: {profile.previous_violations} incident(s) on record.")
388
+ else:
389
+ lines.append(" Chargeback/dispute history: Clean record.")
390
+
391
+ velocity = rng.randint(1, 5) if ad_id not in ad_to_rings else rng.randint(3, 12)
392
+ lines.append(f" Payment method added to {velocity} advertiser account(s) in the last 90 days.")
393
+
394
+ if profile.account_age_days < 30:
395
+ lines.append(f" First charge on this method: {profile.account_age_days} days ago.")
396
+
397
+ return "\n".join(lines)
398
+
399
+
400
+ def _generate_targeting_investigation(
401
+ rng: random.Random,
402
+ ad: Ad,
403
+ all_ads: List[Ad],
404
+ ad_to_rings: Dict[str, List[str]],
405
+ fraud_rings: List[FraudRing],
406
+ ) -> str:
407
+ """Generate targeting overlap investigation text.
408
+
409
+ Ring members share an exact targeting fingerprint, presented as raw data.
410
+ The agent must compare fingerprints across ads to detect collusion.
411
+ """
412
+ lines = [
413
+ f"Targeting Analysis for {ad.ad_id}:",
414
+ f" Declared targeting: {ad.targeting_summary}",
415
+ ]
416
+
417
+ if ad.ad_id in ad_to_rings:
418
+ ring = next(r for r in fraud_rings if ad.ad_id in r.member_ad_ids)
419
+ if "targeting_overlap" in ring.shared_signals:
420
+ lines.append(f" Targeting fingerprint: {ring.shared_signals['targeting_overlap']}")
421
+ overlap_pct = rng.randint(85, 98)
422
+ lines.append(f" Audience overlap with platform average for category: {overlap_pct}%")
423
+ else:
424
+ fingerprint = f"seg_{rng.randint(10000, 99999)}"
425
+ lines.append(f" Targeting fingerprint: {fingerprint}")
426
+ overlap_pct = rng.randint(20, 55)
427
+ lines.append(f" Audience overlap with platform average for category: {overlap_pct}%")
428
+ else:
429
+ fingerprint = f"seg_{rng.randint(10000, 99999)}"
430
+ lines.append(f" Targeting fingerprint: {fingerprint}")
431
+ similar = [a for a in all_ads if a.ad_id != ad.ad_id and a.category == ad.category]
432
+ if similar:
433
+ overlap_pct = rng.randint(30, 65)
434
+ lines.append(f" {len(similar)} other ad(s) in same category ({ad.category}) in queue.")
435
+ lines.append(f" Audience overlap with platform average for category: {overlap_pct}%")
436
+ else:
437
+ overlap_pct = rng.randint(10, 40)
438
+ lines.append(f" Audience overlap with platform average for category: {overlap_pct}%")
439
+
440
+ geo_regions = rng.randint(1, 8) if ad.ground_truth_label != "legit" else rng.randint(1, 3)
441
+ lines.append(f" Geographic regions targeted: {geo_regions}")
442
+
443
+ return "\n".join(lines)
444
+
445
+
446
+ def _generate_creative_investigation(
447
+ rng: random.Random,
448
+ ad: Ad,
449
+ all_ads: List[Ad],
450
+ ad_to_rings: Dict[str, List[str]],
451
+ fraud_rings: List[FraudRing],
452
+ ) -> str:
453
+ """Generate creative similarity investigation text.
454
+
455
+ Ring members share a template hash, presented as raw data. The agent
456
+ must compare hashes across ads to detect reuse.
457
+ """
458
+ lines = [
459
+ f"Creative Analysis for {ad.ad_id}:",
460
+ ]
461
+
462
+ if ad.ad_id in ad_to_rings:
463
+ ring = next(r for r in fraud_rings if ad.ad_id in r.member_ad_ids)
464
+ if "creative_template" in ring.shared_signals:
465
+ lines.append(f" Template hash: {ring.shared_signals['creative_template']}")
466
+ lines.append(f" Image dimensions: {rng.choice(['1200x628', '1080x1080', '1200x1200'])} px")
467
+ lines.append(f" Color palette hash: pal_{rng.randint(100, 999)}")
468
+ lines.append(f" Text-to-image ratio: {rng.randint(18, 25)}%")
469
+ else:
470
+ lines.append(f" Template hash: tmpl_{rng.randint(10000, 99999)}")
471
+ lines.append(f" Image dimensions: {rng.choice(['1200x628', '1080x1080', '1200x1200', '600x600'])} px")
472
+ lines.append(f" Text-to-image ratio: {rng.randint(10, 30)}%")
473
+ else:
474
+ lines.append(f" Template hash: tmpl_{rng.randint(10000, 99999)}")
475
+ lines.append(f" Image dimensions: {rng.choice(['1200x628', '1080x1080'])} px")
476
+ lines.append(f" Text-to-image ratio: {rng.randint(8, 22)}%")
477
+
478
+ if ad.ground_truth_label == "fraud" and ad.fraud_type:
479
+ similarity = rng.uniform(0.3, 0.7)
480
+ else:
481
+ similarity = rng.uniform(0.0, 0.15)
482
+ lines.append(f" Similarity to known scam templates: {similarity:.0%}")
483
+
484
+ return "\n".join(lines)
485
+
486
+
487
+ _LEGIT_OBJECTIVES = ["conversions", "leads", "sales", "app_installs"]
488
+ _FRAUD_OBJECTIVES = ["traffic", "awareness", "reach", "engagement"]
489
+ _LEGIT_BID_STRATEGIES = ["cost_cap", "bid_cap", "target_cost"]
490
+ _FRAUD_BID_STRATEGIES = ["lowest_cost", "lowest_cost", "lowest_cost", "cost_cap"]
491
+
492
+ _LEGIT_PLACEMENTS = [
493
+ ["Facebook Feed", "Instagram Feed"],
494
+ ["Facebook Feed", "Instagram Feed", "Instagram Stories"],
495
+ ["Facebook Feed"],
496
+ ["Facebook Feed", "Instagram Feed", "Instagram Reels"],
497
+ ]
498
+ _FRAUD_PLACEMENTS = [
499
+ ["Audience Network", "Facebook Feed"],
500
+ ["Audience Network", "Facebook Feed", "Instagram Stories"],
501
+ ["Facebook Feed", "Instagram Feed", "Audience Network", "Messenger"],
502
+ ["Audience Network"],
503
+ ]
504
+
505
+
506
+ def _generate_campaign_profile(
507
+ rng: random.Random,
508
+ ad: Ad,
509
+ is_fraud: bool,
510
+ *,
511
+ ring_overrides: Optional[Dict[str, Any]] = None,
512
+ ) -> CampaignProfile:
513
+ """Generate campaign-level metadata for an ad."""
514
+ if is_fraud:
515
+ objective = rng.choice(_FRAUD_OBJECTIVES)
516
+ bid_strategy = rng.choice(_FRAUD_BID_STRATEGIES)
517
+ daily_budget = round(rng.uniform(500, 5000), 2)
518
+ ad_set_count = rng.randint(8, 50)
519
+ placements = rng.choice(_FRAUD_PLACEMENTS)
520
+ else:
521
+ objective = rng.choice(_LEGIT_OBJECTIVES)
522
+ bid_strategy = rng.choice(_LEGIT_BID_STRATEGIES)
523
+ daily_budget = round(rng.uniform(20, 500), 2)
524
+ ad_set_count = rng.randint(1, 5)
525
+ placements = rng.choice(_LEGIT_PLACEMENTS)
526
+
527
+ if ring_overrides:
528
+ objective = ring_overrides.get("objective", objective)
529
+ bid_strategy = ring_overrides.get("bid_strategy", bid_strategy)
530
+
531
+ return CampaignProfile(
532
+ objective=objective,
533
+ bid_strategy=bid_strategy,
534
+ daily_budget_usd=daily_budget,
535
+ ad_set_count=ad_set_count,
536
+ placements=list(placements),
537
+ )
538
+
539
+
540
+ def _generate_campaign_investigation(
541
+ rng: random.Random,
542
+ ad: Ad,
543
+ campaign: CampaignProfile,
544
+ profile: AdvertiserProfile,
545
+ ad_to_rings: Dict[str, List[str]],
546
+ fraud_rings: List[FraudRing],
547
+ ) -> str:
548
+ """Generate campaign structure investigation text.
549
+
550
+ Ring members share campaign configurations but no explicit cross-references.
551
+ The agent must compare objective/bid/budget patterns across ads.
552
+ """
553
+ lines = [
554
+ f"Campaign Structure Analysis for {ad.ad_id}:",
555
+ campaign.to_investigation_text(profile.account_age_days),
556
+ ]
557
+
558
+ config_hash = f"cfg_{hash((campaign.objective, campaign.bid_strategy)) & 0xFFFF:04x}"
559
+ lines.append(f" Campaign configuration fingerprint: {config_hash}")
560
+
561
+ return "\n".join(lines)
data/advertiser_profiles.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Synthetic advertiser profile generation.
3
+
4
+ Each ad has an associated advertiser with history data
5
+ that becomes available when the agent investigates 'advertiser_history'.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from dataclasses import dataclass, field
12
+ from typing import List
13
+
14
+
15
+ @dataclass
16
+ class AdvertiserProfile:
17
+ advertiser_id: str
18
+ account_name: str
19
+ account_age_days: int
20
+ total_spend_usd: float
21
+ previous_violations: int
22
+ previous_bans: int
23
+ ad_volume_last_30d: int
24
+ historical_approval_rate: float
25
+ payment_method_id: str
26
+ payment_method_type: str
27
+ country: str
28
+ verified_business: bool
29
+ account_created_date: str = ""
30
+ spend_velocity: str = ""
31
+ ad_submission_pattern: str = ""
32
+
33
+ def to_investigation_text(self) -> str:
34
+ status = "Verified Business" if self.verified_business else "Unverified"
35
+ violation_note = ""
36
+ if self.previous_violations > 0:
37
+ violation_note = f" ({self.previous_violations} policy violations on record, {self.previous_bans} previous bans)"
38
+ elif self.previous_bans > 0:
39
+ violation_note = f" ({self.previous_bans} previous bans on record)"
40
+
41
+ lines = [
42
+ f"Advertiser: {self.account_name} ({status})",
43
+ f"Account age: {self.account_age_days} days",
44
+ f"Account created: {self.account_created_date}" if self.account_created_date else None,
45
+ f"Country: {self.country}",
46
+ f"Total historical spend: ${self.total_spend_usd:,.2f}",
47
+ f"Ads submitted in last 30 days: {self.ad_volume_last_30d}",
48
+ f"Historical approval rate: {self.historical_approval_rate:.0%}{violation_note}",
49
+ f"Payment method: {self.payment_method_type} (ID: {self.payment_method_id})",
50
+ f"Spend velocity: {self.spend_velocity}" if self.spend_velocity else None,
51
+ f"Submission pattern: {self.ad_submission_pattern}" if self.ad_submission_pattern else None,
52
+ ]
53
+ return "\n".join(l for l in lines if l is not None)
54
+
55
+
56
+ _LEGIT_NAMES = [
57
+ "HomeNest LLC", "StyleHaven Inc", "Chef's Choice Store", "GlowNatural Co",
58
+ "ArtisanHide Crafts", "TaskFlow Pro", "InvoiceSimple Inc", "PeopleFirst HR",
59
+ "VaultBackup Solutions", "MailCraft Inc", "Mike's Plumbing", "Sal's Pizzeria",
60
+ "SparkleHome Services", "Williams Tax Group", "PawPals Chicago",
61
+ "CodeAcademy Pro", "StateU Online", "PrepMaster Tutoring",
62
+ "LensArt Studio", "LinguaViva Education", "FitLife Fitness",
63
+ "ZenFlow Yoga", "NutriPlan Co", "SwiftStride Athletics", "FitVisit",
64
+ ]
65
+
66
+ _SCAM_NAMES = [
67
+ "Digital Marketing Solutions LLC", "Global Deals Marketplace",
68
+ "Premium Offers International", "Quick Rewards Corp",
69
+ "NextGen Trading Ltd", "Elite Ventures Group",
70
+ "Horizon Brands LLC", "TrustPoint Commerce",
71
+ "Alpha Innovations Inc", "PrimeEdge Solutions",
72
+ "Quantum Returns Ltd", "FuturePath Holdings",
73
+ "BlueChip Partners", "ClearView Enterprises",
74
+ "Apex Growth Strategies", "SilverLine Dynamics",
75
+ ]
76
+
77
+ _COUNTRIES_LEGIT = ["United States", "United Kingdom", "Canada", "Australia", "Germany"]
78
+ _COUNTRIES_MIXED = [
79
+ "United States", "United Kingdom", "Netherlands", "Singapore",
80
+ "Hong Kong", "United Arab Emirates", "Estonia", "Georgia",
81
+ ]
82
+
83
+
84
+ def generate_advertiser_profile(
85
+ rng: random.Random,
86
+ ad_id: str,
87
+ is_fraud: bool,
88
+ *,
89
+ payment_method_id: str | None = None,
90
+ ring_created_date: str | None = None,
91
+ ) -> AdvertiserProfile:
92
+ """Generate a synthetic advertiser profile for a single ad."""
93
+ from datetime import date, timedelta
94
+
95
+ if is_fraud:
96
+ account_name = rng.choice(_SCAM_NAMES)
97
+ account_age = rng.randint(1, 90)
98
+ total_spend = round(rng.uniform(0, 500), 2)
99
+ violations = rng.choices([0, 1, 2, 3], weights=[40, 30, 20, 10])[0]
100
+ bans = rng.choices([0, 1, 2], weights=[60, 30, 10])[0]
101
+ ad_volume = rng.randint(5, 80)
102
+ approval_rate = round(rng.uniform(0.3, 0.75), 2)
103
+ country = rng.choice(_COUNTRIES_MIXED)
104
+ verified = rng.random() < 0.15
105
+ pmt_type = rng.choice(["prepaid_card", "crypto", "virtual_card", "wire_transfer", "credit_card"])
106
+ else:
107
+ account_name = rng.choice(_LEGIT_NAMES)
108
+ account_age = rng.randint(180, 2500)
109
+ total_spend = round(rng.uniform(5000, 500000), 2)
110
+ violations = 0
111
+ bans = 0
112
+ ad_volume = rng.randint(1, 20)
113
+ approval_rate = round(rng.uniform(0.9, 1.0), 2)
114
+ country = rng.choice(_COUNTRIES_LEGIT)
115
+ verified = rng.random() < 0.85
116
+ pmt_type = rng.choice(["credit_card", "bank_account", "corporate_card"])
117
+
118
+ if payment_method_id is None:
119
+ payment_method_id = f"pmt_{rng.randint(100000, 999999)}"
120
+
121
+ # Temporal signals
122
+ if ring_created_date:
123
+ created_date = ring_created_date
124
+ else:
125
+ created = date(2026, 4, 6) - timedelta(days=account_age)
126
+ created_date = created.isoformat()
127
+
128
+ if is_fraud:
129
+ spend_per_day = total_spend / max(account_age, 1)
130
+ if spend_per_day > 20:
131
+ spend_velocity = f"${spend_per_day:,.0f}/day avg — ramped from $0 to ${total_spend:,.0f} in {account_age} days"
132
+ else:
133
+ spend_velocity = f"${spend_per_day:,.0f}/day avg over account lifetime"
134
+
135
+ if ad_volume > 20:
136
+ submission_pattern = f"{ad_volume} ads in 30 days (burst: {rng.randint(8, ad_volume)} in a single 24h window)"
137
+ else:
138
+ submission_pattern = f"{ad_volume} ads in 30 days (steady cadence)"
139
+ else:
140
+ spend_per_day = total_spend / max(account_age, 1)
141
+ spend_velocity = f"${spend_per_day:,.0f}/day avg — consistent growth over {account_age} days"
142
+ submission_pattern = f"{ad_volume} ads in 30 days (steady cadence)"
143
+
144
+ return AdvertiserProfile(
145
+ advertiser_id=f"adv_{ad_id}",
146
+ account_name=account_name,
147
+ account_age_days=account_age,
148
+ total_spend_usd=total_spend,
149
+ previous_violations=violations,
150
+ previous_bans=bans,
151
+ ad_volume_last_30d=ad_volume,
152
+ historical_approval_rate=approval_rate,
153
+ payment_method_id=payment_method_id,
154
+ payment_method_type=pmt_type,
155
+ country=country,
156
+ verified_business=verified,
157
+ account_created_date=created_date,
158
+ spend_velocity=spend_velocity,
159
+ ad_submission_pattern=submission_pattern,
160
+ )
data/fraud_patterns.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fraud and legitimate ad templates for synthetic data generation.
3
+
4
+ Templates are organized by difficulty: obvious scams for Task 1,
5
+ sophisticated scams for Task 2, and network-ready scams for Task 3.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import List
12
+
13
+
14
+ @dataclass
15
+ class AdTemplate:
16
+ category: str
17
+ ad_copies: List[str]
18
+ targeting_hints: List[str]
19
+ risk_signals: List[str]
20
+ label: str # "fraud", "legit", or "escalate"
21
+ fraud_type: str = ""
22
+ severity: float = 0.0 # 0.0-1.0
23
+ difficulty: str = "easy" # easy, medium, hard
24
+
25
+
26
+ LEGIT_TEMPLATES: List[AdTemplate] = [
27
+ AdTemplate(
28
+ category="ecommerce",
29
+ ad_copies=[
30
+ "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest.",
31
+ "New arrivals in women's fashion. Curated styles for every occasion. Visit StyleHaven today.",
32
+ "Upgrade your kitchen with premium cookware. Professional-grade, lifetime warranty. Chef's Choice Store.",
33
+ "Organic skincare products made with natural ingredients. Dermatologist tested. GlowNatural.com",
34
+ "Handcrafted leather bags — designed to last. Each piece unique. ArtisanHide.com",
35
+ ],
36
+ targeting_hints=[
37
+ "Women 25-54, interests: home decor, shopping",
38
+ "Women 18-45, interests: fashion, lifestyle",
39
+ "Adults 30-60, interests: cooking, kitchen",
40
+ "Women 22-50, interests: beauty, wellness",
41
+ "Adults 25-55, interests: fashion, luxury goods",
42
+ ],
43
+ risk_signals=[],
44
+ label="legit",
45
+ severity=0.0,
46
+ ),
47
+ AdTemplate(
48
+ category="saas",
49
+ ad_copies=[
50
+ "Streamline your project management. 14-day free trial, no credit card required. Try TaskFlow Pro.",
51
+ "Automate your invoicing in minutes. Trusted by 50,000+ small businesses. InvoiceSimple.",
52
+ "HR management made easy. Onboarding, payroll, compliance — all in one platform. PeopleFirst.",
53
+ "Secure cloud backup for your business. 256-bit encryption. Plans from $9/mo. VaultBackup.",
54
+ "Email marketing that converts. AI-powered subject lines, drag-and-drop editor. MailCraft.",
55
+ ],
56
+ targeting_hints=[
57
+ "Professionals 25-55, interests: business, productivity",
58
+ "Small business owners, interests: finance, accounting",
59
+ "HR managers 30-50, interests: management, recruiting",
60
+ "IT professionals 25-55, interests: cloud, security",
61
+ "Marketers 25-45, interests: digital marketing, email",
62
+ ],
63
+ risk_signals=[],
64
+ label="legit",
65
+ severity=0.0,
66
+ ),
67
+ AdTemplate(
68
+ category="local_service",
69
+ ad_copies=[
70
+ "Need a plumber? Same-day service, licensed & insured. Call Mike's Plumbing — serving Portland since 2008.",
71
+ "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria.",
72
+ "Professional house cleaning. Eco-friendly products, background-checked staff. SparkleHome Services.",
73
+ "Expert tax preparation. CPA with 15 years experience. Free consultation. Williams Tax Group.",
74
+ "Dog walking and pet sitting. GPS-tracked walks, daily photo updates. PawPals Chicago.",
75
+ ],
76
+ targeting_hints=[
77
+ "Homeowners 30-65, Portland metro area",
78
+ "Adults 18-55, Brooklyn, interests: food delivery",
79
+ "Homeowners 25-60, interests: home services",
80
+ "Adults 25-65, tax season, interests: finance",
81
+ "Pet owners 25-50, Chicago metro area",
82
+ ],
83
+ risk_signals=[],
84
+ label="legit",
85
+ severity=0.0,
86
+ ),
87
+ AdTemplate(
88
+ category="education",
89
+ ad_copies=[
90
+ "Learn Python programming in 12 weeks. Live instructor, project-based curriculum. CodeAcademy Pro.",
91
+ "MBA programs online — AACSB accredited. Flexible schedule for working professionals. StateU Online.",
92
+ "SAT prep that works. Average 150-point score improvement. Guaranteed. PrepMaster Tutoring.",
93
+ "Professional photography workshops. Small class sizes, hands-on practice. LensArt Studio.",
94
+ "Spanish fluency in 6 months. Native instructors, conversation-first approach. LinguaViva.",
95
+ ],
96
+ targeting_hints=[
97
+ "Adults 18-35, interests: programming, tech careers",
98
+ "Professionals 25-45, interests: MBA, business education",
99
+ "Parents of teens, interests: college prep, SAT",
100
+ "Adults 25-55, interests: photography, creative arts",
101
+ "Adults 18-50, interests: language learning, travel",
102
+ ],
103
+ risk_signals=[],
104
+ label="legit",
105
+ severity=0.0,
106
+ ),
107
+ AdTemplate(
108
+ category="fitness",
109
+ ad_copies=[
110
+ "Join our community gym. No contracts, cancel anytime. First month $19.99. FitLife Fitness.",
111
+ "Yoga for beginners — online classes with certified instructors. 7-day free trial. ZenFlow Yoga.",
112
+ "Custom meal plans designed by registered dietitians. Achieve your health goals. NutriPlan.",
113
+ "Running shoes engineered for comfort. 90-day return policy. SwiftStride Athletics.",
114
+ "Personal training at home. Certified trainers, flexible scheduling. FitVisit.",
115
+ ],
116
+ targeting_hints=[
117
+ "Adults 18-55, interests: fitness, gym",
118
+ "Adults 25-50, interests: yoga, wellness, mindfulness",
119
+ "Health-conscious adults 22-55, interests: nutrition, diet",
120
+ "Runners 18-55, interests: running, athletics",
121
+ "Adults 25-50, interests: personal training, fitness",
122
+ ],
123
+ risk_signals=[],
124
+ label="legit",
125
+ severity=0.0,
126
+ ),
127
+ ]
128
+
129
+ FRAUD_TEMPLATES: List[AdTemplate] = [
130
+ # === OBVIOUS SCAMS (Task 1) ===
131
+ AdTemplate(
132
+ category="fake_giveaway",
133
+ ad_copies=[
134
+ "CONGRATULATIONS!! You've been selected to win a FREE iPhone 16 Pro!! Click NOW before offer expires!!!",
135
+ "🎉 WINNER ALERT 🎉 Claim your FREE $1000 Amazon Gift Card! Limited time only! Act NOW!",
136
+ "You are the 1,000,000th visitor! Click here to claim your FREE MacBook Pro! Don't miss out!!!",
137
+ "FREE PS5 GIVEAWAY! Just complete a quick survey! Only 3 left! Hurry!!!",
138
+ "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!",
139
+ ],
140
+ targeting_hints=[
141
+ "All adults 18+, broad targeting, worldwide",
142
+ "Adults 18-65, interests: shopping, deals, coupons",
143
+ "Adults 18-35, interests: technology, gaming",
144
+ "Young adults 16-30, interests: gaming, PlayStation",
145
+ "Adults 18-55, interests: cars, Tesla, electric vehicles",
146
+ ],
147
+ risk_signals=[
148
+ "excessive urgency language",
149
+ "too-good-to-be-true offer",
150
+ "excessive punctuation/caps",
151
+ "no purchase necessary claims",
152
+ ],
153
+ label="fraud",
154
+ fraud_type="fake_giveaway",
155
+ severity=0.9,
156
+ difficulty="easy",
157
+ ),
158
+ AdTemplate(
159
+ category="counterfeit_goods",
160
+ ad_copies=[
161
+ "Designer handbags 90% OFF! Gucci, Louis Vuitton, Prada — all authentic! Limited stock! BargainLux.shop",
162
+ "Rolex watches starting at $49.99! 100% genuine! Free worldwide shipping! LuxWatchDeals.net",
163
+ "Nike Air Jordan CLEARANCE — $29.99! All sizes available! Original quality guaranteed! SneakerVault.store",
164
+ "Ray-Ban sunglasses factory direct! 95% discount! Premium quality! SunDealz.com",
165
+ "Canada Goose jackets $79.99! Authentic merchandise, warehouse clearance! ArcticDeals.shop",
166
+ ],
167
+ targeting_hints=[
168
+ "Adults 18-45, interests: luxury brands, fashion deals",
169
+ "Men 25-55, interests: watches, luxury",
170
+ "Young adults 16-30, interests: sneakers, streetwear",
171
+ "Adults 18-50, interests: fashion, sunglasses, deals",
172
+ "Adults 20-45, interests: outdoor gear, fashion",
173
+ ],
174
+ risk_signals=[
175
+ "impossibly low prices for luxury goods",
176
+ "unfamiliar domain",
177
+ "claims of authenticity with extreme discounts",
178
+ ],
179
+ label="fraud",
180
+ fraud_type="counterfeit",
181
+ severity=0.8,
182
+ difficulty="easy",
183
+ ),
184
+ AdTemplate(
185
+ category="miracle_cure",
186
+ ad_copies=[
187
+ "DOCTORS HATE THIS! One simple trick cures diabetes in 7 days! Big Pharma doesn't want you to know!",
188
+ "Lose 30 pounds in 2 weeks with this ancient herb! No diet, no exercise! MiracleSlim pills!",
189
+ "CURE CANCER NATURALLY! This secret remedy has a 99% success rate! Order now before it's banned!",
190
+ "Reverse aging in 14 days! Scientists discover fountain of youth pill! Limited supply!",
191
+ "Grow 3 inches taller at ANY age! Patented growth formula! 100% guaranteed results!",
192
+ ],
193
+ targeting_hints=[
194
+ "Adults 35-70, interests: health, diabetes, natural remedies",
195
+ "Adults 25-60, interests: weight loss, diet, fitness",
196
+ "Adults 40-75, interests: cancer, health, alternative medicine",
197
+ "Adults 35-65, interests: anti-aging, beauty, health",
198
+ "Men 18-40, interests: fitness, height, self-improvement",
199
+ ],
200
+ risk_signals=[
201
+ "unsubstantiated medical claims",
202
+ "conspiracy language ('they don't want you to know')",
203
+ "impossible health promises",
204
+ "urgency/scarcity tactics",
205
+ ],
206
+ label="fraud",
207
+ fraud_type="miracle_cure",
208
+ severity=0.95,
209
+ difficulty="easy",
210
+ ),
211
+ AdTemplate(
212
+ category="advance_fee",
213
+ ad_copies=[
214
+ "URGENT: I am Prince Adebayo of Nigeria. I need your help to transfer $4.5 million. You keep 30%.",
215
+ "Congratulations! You've inherited $2.8 million from a distant relative. Pay $200 processing fee to claim.",
216
+ "WORK FROM HOME! Earn $5000/week stuffing envelopes! No experience needed! Send $50 registration fee.",
217
+ "URGENT BUSINESS PROPOSAL: Oil company executive needs foreign partner. $10M deal. Reply immediately.",
218
+ "You've won the UK National Lottery! Send $100 for tax processing to receive your $1.5M prize.",
219
+ ],
220
+ targeting_hints=[
221
+ "All adults 18+, broad targeting",
222
+ "Adults 30-70, interests: finance, inheritance",
223
+ "Adults 18-65, interests: work from home, jobs",
224
+ "Adults 30-65, interests: business, investment",
225
+ "Adults 25-70, interests: lottery, gambling, luck",
226
+ ],
227
+ risk_signals=[
228
+ "advance fee request",
229
+ "unsolicited money offer",
230
+ "urgency pressure",
231
+ "request for personal information",
232
+ ],
233
+ label="fraud",
234
+ fraud_type="advance_fee_scam",
235
+ severity=0.85,
236
+ difficulty="easy",
237
+ ),
238
+
239
+ # === SOPHISTICATED SCAMS (Task 2) ===
240
+ AdTemplate(
241
+ category="fake_crypto",
242
+ ad_copies=[
243
+ "Invest in DeFi with confidence. Our AI-powered trading platform delivers 12-15% monthly returns. Audited smart contracts. Join CryptoYield Pro.",
244
+ "Stake your crypto with institutional-grade security. 8% APY on BTC, 12% on ETH. Regulated in Singapore. VaultStake Finance.",
245
+ "New token launch: GreenChain ($GRNC) — carbon credit blockchain. Pre-sale live. Backed by major VCs. Read our whitepaper.",
246
+ "Automated crypto arbitrage — our algorithm exploits price differences across exchanges. Average 20% monthly. QuantBot Trading.",
247
+ "Web3 real estate tokenization. Own fractions of premium properties. Starting at $100. Licensed broker-dealer. PropChain.io",
248
+ ],
249
+ targeting_hints=[
250
+ "Men 25-45, interests: cryptocurrency, DeFi, investing",
251
+ "Adults 25-55, interests: crypto staking, passive income",
252
+ "Tech-savvy adults 20-40, interests: blockchain, Web3, tokens",
253
+ "Adults 25-50, interests: algo trading, crypto, finance",
254
+ "Adults 30-55, interests: real estate, investing, crypto",
255
+ ],
256
+ risk_signals=[
257
+ "high guaranteed returns",
258
+ "complex financial product",
259
+ ],
260
+ label="fraud",
261
+ fraud_type="fake_crypto",
262
+ severity=0.7,
263
+ difficulty="medium",
264
+ ),
265
+ AdTemplate(
266
+ category="celebrity_endorsement_fraud",
267
+ ad_copies=[
268
+ "Elon Musk reveals his #1 investment for 2026. 'This will be bigger than Tesla.' Exclusive interview inside.",
269
+ "Warren Buffett's secret stock pick leaked! Financial advisors are scrambling. See the details before it's removed.",
270
+ "Oprah's favorite weight loss secret finally revealed. 'I lost 40 pounds without dieting.' Doctor-approved method.",
271
+ "Mark Cuban: 'I'm putting everything into this AI company.' Find out which stock billionaires are buying.",
272
+ "Taylor Swift partners with new wellness brand. 'It changed my life.' Limited celebrity edition available.",
273
+ ],
274
+ targeting_hints=[
275
+ "Adults 25-55, interests: investing, Elon Musk, technology",
276
+ "Adults 35-70, interests: stocks, Warren Buffett, investing",
277
+ "Women 25-60, interests: weight loss, Oprah, wellness",
278
+ "Adults 25-50, interests: AI, investing, Mark Cuban",
279
+ "Adults 18-45, interests: Taylor Swift, wellness, beauty",
280
+ ],
281
+ risk_signals=[
282
+ "celebrity name used without verification",
283
+ "urgency/exclusivity language",
284
+ ],
285
+ label="fraud",
286
+ fraud_type="fake_endorsement",
287
+ severity=0.65,
288
+ difficulty="medium",
289
+ ),
290
+ AdTemplate(
291
+ category="clone_brand",
292
+ ad_copies=[
293
+ "Amaz0n Warehouse Clearance — Electronics up to 70% off. Official overstock sale. Limited time. amaz0n-deals.com",
294
+ "PayPaI Security Alert: Your account needs verification. Update your information to avoid suspension. Secure portal.",
295
+ "Walmrt+ Members: Exclusive flash sale this weekend only. Save an extra 25% on groceries. Members-only link.",
296
+ "App1e Trade-In Event: Get up to $800 for your old device. Upgrade to iPhone 16. Authorized reseller.",
297
+ "Netfliix Premium — Get 1 year for $29.99! Special promotional price. Activate through our partner link.",
298
+ ],
299
+ targeting_hints=[
300
+ "Adults 18-65, interests: Amazon, deals, electronics",
301
+ "Adults 18-65, interests: online payments, PayPal",
302
+ "Adults 25-60, interests: Walmart, grocery shopping",
303
+ "Adults 18-55, interests: Apple, iPhone, technology",
304
+ "Adults 18-50, interests: Netflix, streaming, entertainment",
305
+ ],
306
+ risk_signals=[
307
+ "brand name misspelling",
308
+ "unofficial domain",
309
+ ],
310
+ label="fraud",
311
+ fraud_type="brand_impersonation",
312
+ severity=0.75,
313
+ difficulty="medium",
314
+ ),
315
+ AdTemplate(
316
+ category="gray_area_supplements",
317
+ ad_copies=[
318
+ "NeuroPeak — clinically studied nootropic for enhanced focus and memory. 30-day money-back guarantee. FDA-registered facility.",
319
+ "TestoMax Elite — natural testosterone support for men over 40. Doctor-formulated. Published clinical trial results.",
320
+ "SlimBiome Pro — proprietary probiotic blend for healthy weight management. Featured in Health Magazine. GMP certified.",
321
+ "Joint Restore Plus — advanced collagen peptide formula. Thousands of 5-star reviews. Orthopedist recommended.",
322
+ "SleepDeep Ultra — melatonin-free natural sleep aid. Non-habit forming. Money-back guarantee. PharmGrade Labs.",
323
+ ],
324
+ targeting_hints=[
325
+ "Adults 25-55, interests: nootropics, productivity, brain health",
326
+ "Men 40-65, interests: men's health, testosterone, fitness",
327
+ "Adults 30-60, interests: weight loss, probiotics, gut health",
328
+ "Adults 45-75, interests: joint health, supplements, arthritis",
329
+ "Adults 30-65, interests: sleep, insomnia, wellness",
330
+ ],
331
+ risk_signals=[
332
+ "supplement with strong efficacy claims",
333
+ "medical-sounding language",
334
+ ],
335
+ label="escalate",
336
+ fraud_type="gray_area",
337
+ severity=0.4,
338
+ difficulty="medium",
339
+ ),
340
+
341
+ # === NETWORK SCAMS (Task 3 — individual ads that belong to fraud rings) ===
342
+ # These templates look almost entirely legitimate on the surface.
343
+ # The fraud is only detectable through cross-ad investigation.
344
+ AdTemplate(
345
+ category="network_crypto",
346
+ ad_copies=[
347
+ "Earn passive income with our DeFi staking pool. Community-driven, transparent governance. BlockYield.io",
348
+ "Next-gen DEX with lowest fees. Trade with zero slippage on major pairs. SwapNova Protocol.",
349
+ "NFT marketplace for digital artists. Mint, list, and sell with 2% fees. Creator-first platform. ArtBlock.exchange",
350
+ "Cross-chain bridge with military-grade security. Move assets between chains in seconds. ChainLink Pro.",
351
+ "Crypto tax software — auto-import from 50+ exchanges. CPA-approved reports. TaxChain Solutions.",
352
+ ],
353
+ targeting_hints=[
354
+ "Adults 20-40, interests: DeFi, crypto staking",
355
+ "Adults 20-45, interests: crypto trading, DEX",
356
+ "Adults 20-40, interests: NFT, digital art, crypto",
357
+ "Adults 25-45, interests: cross-chain, crypto bridges",
358
+ "Adults 25-55, interests: crypto, tax, finance",
359
+ ],
360
+ risk_signals=[],
361
+ label="fraud",
362
+ fraud_type="coordinated_network",
363
+ severity=0.6,
364
+ difficulty="hard",
365
+ ),
366
+ AdTemplate(
367
+ category="network_ecommerce",
368
+ ad_copies=[
369
+ "Premium wireless earbuds — ANC, 40hr battery. Compare to AirPods Pro at half the price. SoundElite Store.",
370
+ "Smart home security camera — 2K resolution, night vision, cloud storage. TechGuard Home.",
371
+ "Portable power station for camping. 1000W, solar-ready. Adventure-proof design. PowerTrail Gear.",
372
+ "Ergonomic office chair — lumbar support, breathable mesh. 5-year warranty. WorkComfort Direct.",
373
+ "Robot vacuum with LiDAR mapping. Self-emptying dock. Smart home compatible. CleanBot Pro.",
374
+ ],
375
+ targeting_hints=[
376
+ "Adults 18-45, interests: tech, audio, headphones",
377
+ "Adults 25-55, interests: home security, smart home",
378
+ "Adults 25-50, interests: camping, outdoor gear",
379
+ "Adults 25-55, interests: home office, ergonomics",
380
+ "Adults 25-55, interests: smart home, cleaning",
381
+ ],
382
+ risk_signals=[],
383
+ label="fraud",
384
+ fraud_type="coordinated_network",
385
+ severity=0.5,
386
+ difficulty="hard",
387
+ ),
388
+ AdTemplate(
389
+ category="network_fintech",
390
+ ad_copies=[
391
+ "Simplify your bookkeeping. AI-powered categorization, bank sync, tax-ready reports. LedgerFlow.",
392
+ "Expense management for growing teams. Real-time tracking, receipt scanning, approval workflows. SpendWise.",
393
+ "Invoicing made simple. Send professional invoices in 30 seconds. Accept 40+ payment methods. BillStream.",
394
+ "Payroll for startups — automated tax filings, direct deposit, compliance included. PayRun HR.",
395
+ "Business credit monitoring. Real-time alerts, score tracking, credit-building tips. BizCredit Insights.",
396
+ ],
397
+ targeting_hints=[
398
+ "Small business owners 28-55, interests: accounting, bookkeeping",
399
+ "Finance managers 30-50, interests: expense management, SaaS",
400
+ "Freelancers 22-45, interests: invoicing, self-employment",
401
+ "Startup founders 25-45, interests: payroll, HR tech",
402
+ "Entrepreneurs 30-55, interests: business credit, finance",
403
+ ],
404
+ risk_signals=[],
405
+ label="fraud",
406
+ fraud_type="coordinated_network",
407
+ severity=0.55,
408
+ difficulty="hard",
409
+ ),
410
+ AdTemplate(
411
+ category="network_health",
412
+ ad_copies=[
413
+ "Telehealth appointments in minutes. Board-certified physicians, prescriptions delivered. MediConnect Health.",
414
+ "Mental wellness platform. Licensed therapists, flexible scheduling, insurance accepted. MindBridge Care.",
415
+ "At-home lab testing. Comprehensive panels, fast results, doctor-reviewed. HealthCheck Direct.",
416
+ "Nutrition coaching backed by science. Personalized meal plans from registered dietitians. NutriGuide Pro.",
417
+ "Fitness tracking reimagined. AI-powered workout plans, progress analytics, community support. FitMetrics.",
418
+ ],
419
+ targeting_hints=[
420
+ "Adults 25-60, interests: telemedicine, healthcare",
421
+ "Adults 25-50, interests: mental health, therapy, wellness",
422
+ "Health-conscious adults 30-55, interests: lab testing, preventive health",
423
+ "Adults 25-50, interests: nutrition, diet planning, health",
424
+ "Fitness enthusiasts 20-45, interests: fitness tracking, wearables",
425
+ ],
426
+ risk_signals=[],
427
+ label="fraud",
428
+ fraud_type="coordinated_network",
429
+ severity=0.5,
430
+ difficulty="hard",
431
+ ),
432
+ ]
data/landing_pages.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simulated landing page investigation data.
3
+
4
+ When the agent investigates 'landing_page' for an ad, this module
5
+ generates the findings text based on the ad's ground truth label.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from dataclasses import dataclass
12
+ from typing import List
13
+
14
+
15
+ @dataclass
16
+ class LandingPageData:
17
+ domain: str
18
+ domain_age_days: int
19
+ ssl_valid: bool
20
+ registrar: str
21
+ content_summary: str
22
+ redirect_chain: List[str]
23
+ similarity_to_known_scams: float # 0.0-1.0
24
+ key_claims: List[str]
25
+ suspicious_elements: List[str]
26
+
27
+ def to_investigation_text(self) -> str:
28
+ ssl_status = "Valid SSL certificate" if self.ssl_valid else "NO SSL / expired certificate"
29
+ redirects = " -> ".join(self.redirect_chain) if self.redirect_chain else "No redirects (direct)"
30
+ suspicious = "\n".join(f" - {s}" for s in self.suspicious_elements) if self.suspicious_elements else " None detected"
31
+ claims = "\n".join(f" - {c}" for c in self.key_claims) if self.key_claims else " None"
32
+
33
+ lines = [
34
+ f"Domain: {self.domain}",
35
+ f"Domain age: {self.domain_age_days} days",
36
+ f"SSL: {ssl_status}",
37
+ f"Registrar: {self.registrar}",
38
+ f"WHOIS privacy: {'Enabled' if 'privacy' in self.registrar.lower() or 'proxy' in self.registrar.lower() else 'Disabled (registrant info public)'}",
39
+ f"Redirect chain: {redirects}",
40
+ f"Similarity to known scam templates: {self.similarity_to_known_scams:.0%}",
41
+ f"Key claims on page:\n{claims}",
42
+ f"Suspicious elements:\n{suspicious}",
43
+ f"Content summary: {self.content_summary}",
44
+ ]
45
+ return "\n".join(lines)
46
+
47
+
48
+ _LEGIT_REGISTRARS = ["GoDaddy", "Namecheap", "Google Domains", "Cloudflare Registrar", "AWS Route 53"]
49
+ _SUSPICIOUS_REGISTRARS = ["NameSilo", "Epik", "Njalla (privacy)", "Tucows (privacy proxy)", "Openprovider"]
50
+
51
+ _LEGIT_DOMAINS_SUFFIXES = [".com", ".co", ".org", ".io", ".net"]
52
+ _SCAM_DOMAINS_SUFFIXES = [".shop", ".store", ".xyz", ".click", ".top", ".buzz", ".site"]
53
+
54
+
55
+ def generate_landing_page(
56
+ rng: random.Random,
57
+ ad_id: str,
58
+ is_fraud: bool,
59
+ fraud_type: str = "",
60
+ *,
61
+ domain_override: str | None = None,
62
+ registrar_override: str | None = None,
63
+ ) -> LandingPageData:
64
+ """Generate simulated landing page investigation data."""
65
+
66
+ base_word = rng.choice(["deal", "offer", "shop", "store", "buy", "get", "best", "top", "pro", "elite"])
67
+
68
+ if is_fraud:
69
+ suffix = rng.choice(_SCAM_DOMAINS_SUFFIXES)
70
+ domain = domain_override or f"{base_word}{rng.randint(10, 9999)}{suffix}"
71
+ domain_age = rng.randint(1, 60)
72
+ ssl_valid = rng.random() < 0.4
73
+ registrar = registrar_override or rng.choice(_SUSPICIOUS_REGISTRARS)
74
+ similarity = round(rng.uniform(0.5, 0.95), 2)
75
+
76
+ redirect_count = rng.randint(1, 4)
77
+ redirect_chain = [domain]
78
+ for _ in range(redirect_count):
79
+ redirect_chain.append(f"redirect{rng.randint(1, 999)}{rng.choice(['.click', '.top', '.xyz'])}")
80
+ redirect_chain.append(f"final-{rng.randint(1, 999)}{rng.choice(['.site', '.store'])}")
81
+
82
+ suspicious = _get_fraud_suspicious_elements(rng, fraud_type)
83
+ claims = _get_fraud_claims(rng, fraud_type)
84
+ content = _get_fraud_content_summary(rng, fraud_type)
85
+ else:
86
+ suffix = rng.choice(_LEGIT_DOMAINS_SUFFIXES)
87
+ brand = rng.choice(["homenest", "stylehaven", "taskflow", "fitlife", "sparkle", "codeacademy", "nutri"])
88
+ domain = domain_override or f"{brand}{suffix}"
89
+ domain_age = rng.randint(365, 5000)
90
+ ssl_valid = True
91
+ registrar = registrar_override or rng.choice(_LEGIT_REGISTRARS)
92
+ similarity = round(rng.uniform(0.0, 0.15), 2)
93
+ redirect_chain = []
94
+ suspicious = []
95
+ claims = _get_legit_claims(rng)
96
+ content = _get_legit_content_summary(rng)
97
+
98
+ return LandingPageData(
99
+ domain=domain,
100
+ domain_age_days=domain_age,
101
+ ssl_valid=ssl_valid,
102
+ registrar=registrar,
103
+ content_summary=content,
104
+ redirect_chain=redirect_chain,
105
+ similarity_to_known_scams=similarity,
106
+ key_claims=claims,
107
+ suspicious_elements=suspicious,
108
+ )
109
+
110
+
111
+ def _get_fraud_suspicious_elements(rng: random.Random, fraud_type: str) -> List[str]:
112
+ common = [
113
+ "No physical address listed",
114
+ "No contact phone number",
115
+ "Privacy policy copied from template",
116
+ "Terms of service link returns 404",
117
+ ]
118
+ type_specific = {
119
+ "fake_giveaway": ["Countdown timer with fake urgency", "Requests personal info before any value provided"],
120
+ "counterfeit": ["Product images appear stolen from official brand site", "Price comparison shows 90%+ discount vs retail"],
121
+ "miracle_cure": ["FDA disclaimer buried in footer", "Before/after photos appear digitally altered"],
122
+ "advance_fee_scam": ["Wire transfer requested upfront", "No company registration found"],
123
+ "fake_crypto": ["Smart contract not verified on-chain", "Team photos are stock images"],
124
+ "fake_endorsement": ["Celebrity quote not found in any verified source", "Affiliate tracking parameters in URL"],
125
+ "brand_impersonation": ["Domain mimics well-known brand with character substitution", "Logo appears edited from official assets"],
126
+ "gray_area": ["Citations reference unpublished or retracted studies", "Testimonials lack verifiable details"],
127
+ "coordinated_network": ["Identical page template used across multiple domains", "Contact form submits to third-party aggregator"],
128
+ }
129
+ elements = list(common)
130
+ elements.extend(type_specific.get(fraud_type, []))
131
+ rng.shuffle(elements)
132
+ return elements[: rng.randint(2, min(5, len(elements)))]
133
+
134
+
135
+ def _get_fraud_claims(rng: random.Random, fraud_type: str) -> List[str]:
136
+ by_type = {
137
+ "fake_giveaway": ["100% free, no purchase necessary", "Guaranteed winner", "Act now — expires in 2 hours"],
138
+ "counterfeit": ["Authentic products", "Direct from manufacturer", "90% below retail price"],
139
+ "miracle_cure": ["Clinically proven results", "Works in 7 days or less", "Endorsed by doctors"],
140
+ "advance_fee_scam": ["Guaranteed returns", "Low processing fee required", "Confidential opportunity"],
141
+ "fake_crypto": ["12-15% monthly returns guaranteed", "Audited by top security firms", "Risk-free investment"],
142
+ "fake_endorsement": ["As seen on TV", "Celebrity recommended", "Limited exclusive offer"],
143
+ "brand_impersonation": ["Official authorized retailer", "Factory direct pricing", "Same quality, lower price"],
144
+ "gray_area": ["Clinically studied ingredients", "Doctor recommended", "30-day money-back guarantee"],
145
+ "coordinated_network": ["Limited stock available", "Thousands of 5-star reviews", "Fast free shipping"],
146
+ }
147
+ return by_type.get(fraud_type, ["Special limited offer", "Act now"])
148
+
149
+
150
+ def _get_fraud_content_summary(rng: random.Random, fraud_type: str) -> str:
151
+ summaries = {
152
+ "fake_giveaway": "Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.",
153
+ "counterfeit": "Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.",
154
+ "miracle_cure": "Long-form sales page with testimonials, before/after images, and pseudoscientific explanations. Multiple urgency CTAs.",
155
+ "advance_fee_scam": "Simple page with a letter-style appeal requesting wire transfer. Grammar errors. No company details.",
156
+ "fake_crypto": "Professional-looking platform with dashboard screenshots. Whitepaper link leads to a generic PDF. Team bios use stock photos.",
157
+ "fake_endorsement": "News article-style page with celebrity photos. Comments section appears pre-populated with positive feedback.",
158
+ "brand_impersonation": "Near-replica of the official brand website. URL uses character substitution. Cart and checkout flow functional but data destination unclear.",
159
+ "gray_area": "Well-designed supplement product page. Ingredient list present but efficacy claims exceed scientific evidence. Has a real return policy.",
160
+ "coordinated_network": "Standard e-commerce template. Products appear legitimate but company details are vague. Identical template to other flagged sites.",
161
+ }
162
+ return summaries.get(fraud_type, "Generic landing page with minimal content and heavy use of urgency language.")
163
+
164
+
165
+ def _get_legit_claims(rng: random.Random) -> List[str]:
166
+ options = [
167
+ "Free shipping on orders over $50",
168
+ "14-day free trial",
169
+ "Money-back guarantee",
170
+ "Established since 2010",
171
+ "4.5-star average customer rating",
172
+ "Licensed and insured",
173
+ "BBB accredited",
174
+ "Certified organic ingredients",
175
+ ]
176
+ rng.shuffle(options)
177
+ return options[: rng.randint(2, 4)]
178
+
179
+
180
+ def _get_legit_content_summary(rng: random.Random) -> str:
181
+ options = [
182
+ "Well-structured business website with clear product catalog, about page, contact information, and shipping policy.",
183
+ "Professional service website with team bios, case studies, client testimonials, and clear pricing.",
184
+ "E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.",
185
+ "SaaS landing page with feature breakdown, pricing tiers, integration docs, and live demo option.",
186
+ ]
187
+ return rng.choice(options)
data/network_generator.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fraud network (ring) generation for Task 3 using networkx.
3
+
4
+ Generates complex fraud ring topologies:
5
+ - Simple cliques (3-5 ads fully connected)
6
+ - Chain structures (A-B via payment, B-C via creative — transitive reasoning required)
7
+ - Hub-and-spoke (one master account linked to satellites)
8
+
9
+ Individual ads in a ring may look borderline; the signal is in the connections.
10
+ Each edge in the graph carries the signal type that connects the two ads.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ from dataclasses import dataclass, field
17
+ from typing import Dict, List, Set, Tuple
18
+
19
+ import networkx as nx
20
+
21
+
22
+ @dataclass
23
+ class FraudRing:
24
+ ring_id: str
25
+ member_ad_ids: List[str]
26
+ shared_signals: Dict[str, str] # signal_type -> shared_value
27
+ topology: str = "clique" # clique, chain, hub_spoke
28
+
29
+ @property
30
+ def size(self) -> int:
31
+ return len(self.member_ad_ids)
32
+
33
+
34
+ _RING_TOPOLOGIES = ["clique", "chain", "hub_spoke"]
35
+
36
+ _SIGNAL_POOL_KEYS = ["payment_method", "domain_registrar", "creative_template", "targeting_overlap"]
37
+
38
+ _REGISTRAR_CHOICES = ["Njalla (privacy)", "Epik", "NameSilo", "Tucows (privacy proxy)"]
39
+ _TARGETING_CHOICES = [
40
+ "Men 25-45, crypto+investing, US+UK+AU",
41
+ "Adults 18-35, tech+gaming, worldwide",
42
+ "Women 25-55, health+beauty, US+CA",
43
+ "Adults 30-60, finance+real-estate, US+UK",
44
+ "Adults 20-40, e-commerce+dropshipping, US+EU",
45
+ ]
46
+
47
+
48
+ def _make_signal_pool(rng: random.Random, ring_index: int) -> Dict[str, str]:
49
+ """Generate a pool of shared signal values for one ring."""
50
+ return {
51
+ "payment_method": f"pmt_ring_{rng.randint(10000, 99999)}",
52
+ "domain_registrar": rng.choice(_REGISTRAR_CHOICES),
53
+ "creative_template": f"tmpl_{rng.randint(1000, 9999)}",
54
+ "targeting_overlap": rng.choice(_TARGETING_CHOICES),
55
+ }
56
+
57
+
58
+ def generate_fraud_networks(
59
+ rng: random.Random,
60
+ n_rings: int,
61
+ available_fraud_ad_ids: List[str],
62
+ ) -> Tuple[List[FraudRing], Dict[str, List[str]]]:
63
+ """
64
+ Generate fraud ring structures with complex topologies.
65
+
66
+ Returns:
67
+ rings: list of FraudRing objects
68
+ ad_to_rings: mapping from ad_id to list of ring_ids it belongs to
69
+ """
70
+ G = nx.Graph()
71
+ rings: List[FraudRing] = []
72
+ ad_to_rings: Dict[str, List[str]] = {}
73
+
74
+ remaining = list(available_fraud_ad_ids)
75
+ rng.shuffle(remaining)
76
+
77
+ for i in range(n_rings):
78
+ if len(remaining) < 3:
79
+ break
80
+ ring_size = rng.randint(3, min(5, len(remaining)))
81
+
82
+ members = remaining[:ring_size]
83
+ remaining = remaining[ring_size:]
84
+
85
+ topology = rng.choice(_RING_TOPOLOGIES)
86
+ signal_pool = _make_signal_pool(rng, i)
87
+
88
+ signal_keys = list(_SIGNAL_POOL_KEYS)
89
+ rng.shuffle(signal_keys)
90
+ n_shared = rng.randint(2, len(signal_keys))
91
+ shared_signals = {k: signal_pool[k] for k in signal_keys[:n_shared]}
92
+
93
+ _add_edges_for_topology(G, members, shared_signals, topology, rng)
94
+
95
+ ring_id = f"ring_{i}"
96
+ ring = FraudRing(
97
+ ring_id=ring_id,
98
+ member_ad_ids=members,
99
+ shared_signals=shared_signals,
100
+ topology=topology,
101
+ )
102
+ rings.append(ring)
103
+
104
+ for ad_id in members:
105
+ ad_to_rings.setdefault(ad_id, []).append(ring_id)
106
+ G.add_node(ad_id, ring_id=ring_id)
107
+
108
+ # Optionally create bridge nodes between rings for extra complexity
109
+ if len(rings) >= 2 and remaining:
110
+ _add_bridge_ads(G, rings, remaining, ad_to_rings, rng)
111
+
112
+ return rings, ad_to_rings
113
+
114
+
115
+ def _add_edges_for_topology(
116
+ G: nx.Graph,
117
+ members: List[str],
118
+ shared_signals: Dict[str, str],
119
+ topology: str,
120
+ rng: random.Random,
121
+ ) -> None:
122
+ """Add edges to the graph based on the ring topology."""
123
+ signal_types = list(shared_signals.keys())
124
+
125
+ if topology == "clique":
126
+ for i, a in enumerate(members):
127
+ for b in members[i + 1:]:
128
+ signal = rng.choice(signal_types)
129
+ G.add_edge(a, b, signal_type=signal, signal_value=shared_signals[signal])
130
+
131
+ elif topology == "chain":
132
+ for idx in range(len(members) - 1):
133
+ signal = signal_types[idx % len(signal_types)]
134
+ G.add_edge(
135
+ members[idx], members[idx + 1],
136
+ signal_type=signal, signal_value=shared_signals[signal],
137
+ )
138
+
139
+ elif topology == "hub_spoke":
140
+ hub = members[0]
141
+ for spoke in members[1:]:
142
+ signal = rng.choice(signal_types)
143
+ G.add_edge(hub, spoke, signal_type=signal, signal_value=shared_signals[signal])
144
+
145
+
146
+ def _add_bridge_ads(
147
+ G: nx.Graph,
148
+ rings: List[FraudRing],
149
+ remaining: List[str],
150
+ ad_to_rings: Dict[str, List[str]],
151
+ rng: random.Random,
152
+ ) -> None:
153
+ """Optionally link two rings via a shared bridge ad from the remaining pool."""
154
+ if len(remaining) < 1 or len(rings) < 2:
155
+ return
156
+
157
+ bridge_ad = remaining.pop(0)
158
+ r1, r2 = rings[0], rings[1]
159
+
160
+ bridge_to_r1 = rng.choice(r1.member_ad_ids)
161
+ bridge_to_r2 = rng.choice(r2.member_ad_ids)
162
+
163
+ r1.member_ad_ids.append(bridge_ad)
164
+ ad_to_rings.setdefault(bridge_ad, []).extend([r1.ring_id, r2.ring_id])
165
+
166
+ sig_key = rng.choice(list(r1.shared_signals.keys()))
167
+ G.add_edge(bridge_ad, bridge_to_r1, signal_type=sig_key, signal_value=r1.shared_signals[sig_key])
168
+
169
+ sig_key2 = rng.choice(list(r2.shared_signals.keys()))
170
+ G.add_edge(bridge_ad, bridge_to_r2, signal_type=sig_key2, signal_value=r2.shared_signals[sig_key2])
171
+
172
+
173
+ def get_ring_shared_signal_text(ring: FraudRing) -> str:
174
+ """Describe the shared signals in a ring (for grader/debug use)."""
175
+ lines = [f"Fraud Ring {ring.ring_id} ({ring.size} members, topology={ring.topology}):"]
176
+ lines.append(f" Members: {', '.join(ring.member_ad_ids)}")
177
+ lines.append(" Shared signals:")
178
+ for signal_type, value in ring.shared_signals.items():
179
+ lines.append(f" - {signal_type}: {value}")
180
+ return "\n".join(lines)
181
+
182
+
183
+ def build_ground_truth_graph(rings: List[FraudRing]) -> nx.Graph:
184
+ """Reconstruct the full ground truth network graph from rings.
185
+
186
+ Used by graders to compute the expected set of edges.
187
+ """
188
+ G = nx.Graph()
189
+ for ring in rings:
190
+ for i, a in enumerate(ring.member_ad_ids):
191
+ G.add_node(a, ring_id=ring.ring_id)
192
+ for b in ring.member_ad_ids[i + 1:]:
193
+ G.add_edge(a, b, ring_id=ring.ring_id)
194
+ return G
graders/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graders for scoring agent performance on each task."""
2
+
3
+ from .base_grader import grade_episode
4
+ from .task1_grader import Task1Grader
5
+ from .task2_grader import Task2Grader
6
+ from .task3_grader import Task3Grader
7
+
8
+ GRADERS = {
9
+ "task_1": Task1Grader(),
10
+ "task_2": Task2Grader(),
11
+ "task_3": Task3Grader(),
12
+ }
13
+
14
+ __all__ = ["grade_episode", "Task1Grader", "Task2Grader", "Task3Grader", "GRADERS"]
graders/base_grader.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared grading logic for all tasks.
3
+
4
+ Each grader produces a 0.0-1.0 score by normalizing raw reward
5
+ between theoretical worst and best cases.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ _NORM_EPS = 1e-8 # For numerical stability
15
+
16
+
17
+ @dataclass
18
+ class VerdictResult:
19
+ ad_id: str
20
+ verdict: str # approve, reject, escalate
21
+ confidence: float
22
+ ground_truth: str # fraud, legit, escalate
23
+ auto_approved: bool = False
24
+
25
+
26
+ @dataclass
27
+ class LinkResult:
28
+ ad_id_1: str
29
+ ad_id_2: str
30
+ correct: bool
31
+
32
+
33
+ @dataclass
34
+ class EpisodeRecord:
35
+ """All data needed for grading a completed episode."""
36
+ task_id: str
37
+ total_steps: int
38
+ action_budget: int
39
+ verdicts: List[VerdictResult]
40
+ links: List[LinkResult]
41
+ ads_metadata: List[Dict[str, Any]] # [{ad_id, ground_truth, severity, ...}]
42
+ n_fraud_rings: int = 0
43
+ ring_sizes: List[int] = None
44
+
45
+ def __post_init__(self):
46
+ if self.ring_sizes is None:
47
+ self.ring_sizes = []
48
+
49
+
50
+ class BaseGrader(ABC):
51
+ """Abstract grader that scores an episode 0.0-1.0."""
52
+
53
+ @abstractmethod
54
+ def grade(self, record: EpisodeRecord) -> float:
55
+ """Return a score in [0.0, 1.0]."""
56
+ ...
57
+
58
+ def _count_correct_verdicts(self, verdicts: List[VerdictResult]) -> int:
59
+ return sum(1 for v in verdicts if self._is_correct(v))
60
+
61
+ def _count_false_positives(self, verdicts: List[VerdictResult]) -> int:
62
+ return sum(
63
+ 1 for v in verdicts
64
+ if v.verdict == "reject" and v.ground_truth == "legit"
65
+ )
66
+
67
+ def _count_false_negatives(self, verdicts: List[VerdictResult]) -> int:
68
+ return sum(
69
+ 1 for v in verdicts
70
+ if v.verdict == "approve" and v.ground_truth == "fraud"
71
+ )
72
+
73
+ def _is_correct(self, v: VerdictResult) -> bool:
74
+ return (
75
+ (v.verdict == "reject" and v.ground_truth == "fraud")
76
+ or (v.verdict == "approve" and v.ground_truth == "legit")
77
+ or (v.verdict == "escalate" and v.ground_truth == "escalate")
78
+ )
79
+
80
+ def _verdict_reward(self, v: VerdictResult, severity: float = 0.5) -> float:
81
+ if v.verdict == "reject" and v.ground_truth == "fraud":
82
+ return 0.3 + 0.1 * severity
83
+ elif v.verdict == "approve" and v.ground_truth == "legit":
84
+ return 0.1
85
+ elif v.verdict == "escalate" and v.ground_truth == "escalate":
86
+ return 0.15
87
+ elif v.verdict == "reject" and v.ground_truth == "legit":
88
+ return -0.35
89
+ elif v.verdict == "approve" and v.ground_truth == "fraud":
90
+ return -0.5
91
+ elif v.verdict == "escalate":
92
+ return -0.05
93
+ elif v.verdict == "approve" and v.ground_truth == "escalate":
94
+ return -0.15
95
+ elif v.verdict == "reject" and v.ground_truth == "escalate":
96
+ return -0.1
97
+ return -0.05
98
+
99
+ def _normalize(self, raw: float, best: float, worst: float) -> float:
100
+ score_range = best - worst
101
+ if score_range <= 0:
102
+ return 0.5
103
+ normalized = (raw - worst) / (score_range + _NORM_EPS)
104
+ return max(0.0, min(1.0, normalized))
105
+
106
+
107
+ def grade_episode(record: EpisodeRecord) -> float:
108
+ """Grade an episode using the appropriate task grader."""
109
+ from . import GRADERS
110
+ grader = GRADERS.get(record.task_id)
111
+ if grader is None:
112
+ raise ValueError(f"Unknown task_id: {record.task_id}")
113
+ return grader.grade(record)
graders/task1_grader.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 1 Grader: Basic Ad Triage (Easy).
3
+
4
+ Scores based on verdict accuracy only. No network or calibration bonuses.
5
+ 5 ads, 25 budget — a decent LLM should score 0.6-0.8.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base_grader import BaseGrader, EpisodeRecord
11
+
12
+
13
+ class Task1Grader(BaseGrader):
14
+ def grade(self, record: EpisodeRecord) -> float:
15
+ raw = 0.0
16
+ best = 0.0
17
+ worst = 0.0
18
+
19
+ severity_map = {
20
+ m["ad_id"]: m.get("severity", 0.5) for m in record.ads_metadata
21
+ }
22
+
23
+ for v in record.verdicts:
24
+ severity = severity_map.get(v.ad_id, 0.5)
25
+ raw += self._verdict_reward(v, severity)
26
+
27
+ if v.ground_truth == "fraud":
28
+ best += 0.3 + 0.1 * severity
29
+ worst -= 0.5
30
+ elif v.ground_truth == "legit":
31
+ best += 0.1
32
+ worst -= 0.35
33
+ elif v.ground_truth == "escalate":
34
+ best += 0.15
35
+ worst -= 0.15
36
+
37
+ n_investigations = max(0, record.total_steps - len([
38
+ v for v in record.verdicts if not v.auto_approved
39
+ ]))
40
+ raw -= n_investigations * 0.02
41
+ worst -= record.action_budget * 0.02
42
+
43
+ best += 0.2
44
+
45
+ if record.total_steps > 0:
46
+ correct = self._count_correct_verdicts(record.verdicts)
47
+ raw += (correct / record.total_steps) * 0.2
48
+
49
+ return self._normalize(raw, best, worst)
graders/task2_grader.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 2 Grader: Sophisticated Fraud Under Budget Pressure (Medium).
3
+
4
+ Adds budget efficiency bonus and calibration scoring on top of verdict accuracy.
5
+ 12 ads, 30 budget — requires triage, expected baseline 0.3-0.5.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base_grader import BaseGrader, EpisodeRecord
11
+
12
+
13
+ class Task2Grader(BaseGrader):
14
+ def grade(self, record: EpisodeRecord) -> float:
15
+ raw = 0.0
16
+ best = 0.0
17
+ worst = 0.0
18
+
19
+ severity_map = {
20
+ m["ad_id"]: m.get("severity", 0.5) for m in record.ads_metadata
21
+ }
22
+
23
+ for v in record.verdicts:
24
+ severity = severity_map.get(v.ad_id, 0.5)
25
+ raw += self._verdict_reward(v, severity)
26
+
27
+ if v.ground_truth == "fraud":
28
+ best += 0.3 + 0.1 * severity
29
+ worst -= 0.5
30
+ elif v.ground_truth == "legit":
31
+ best += 0.1
32
+ worst -= 0.35
33
+ elif v.ground_truth == "escalate":
34
+ best += 0.15
35
+ worst -= 0.15
36
+
37
+ n_investigations = max(0, record.total_steps - len([
38
+ v for v in record.verdicts if not v.auto_approved
39
+ ]))
40
+ raw -= n_investigations * 0.02
41
+ worst -= record.action_budget * 0.02
42
+
43
+ # Budget efficiency bonus
44
+ best += 0.2
45
+ if record.total_steps > 0:
46
+ correct = self._count_correct_verdicts(record.verdicts)
47
+ raw += (correct / record.total_steps) * 0.2
48
+
49
+ # Calibration bonus: reward agents whose stated confidence
50
+ # correlates with actual accuracy
51
+ calibration = self._compute_calibration(record)
52
+ raw += calibration * 0.15
53
+ best += 0.15
54
+
55
+ return self._normalize(raw, best, worst)
56
+
57
+ def _compute_calibration(self, record: EpisodeRecord) -> float:
58
+ """
59
+ Measure how well confidence tracks correctness.
60
+
61
+ Bins verdicts by confidence and checks if the fraction correct
62
+ in each bin roughly matches the stated confidence.
63
+ """
64
+ manual = [v for v in record.verdicts if not v.auto_approved]
65
+ if len(manual) < 3:
66
+ return 0.5
67
+
68
+ bins = {"low": [], "mid": [], "high": []}
69
+ for v in manual:
70
+ if v.confidence < 0.4:
71
+ bins["low"].append(v)
72
+ elif v.confidence < 0.7:
73
+ bins["mid"].append(v)
74
+ else:
75
+ bins["high"].append(v)
76
+
77
+ errors = []
78
+ for _label, group in bins.items():
79
+ if not group:
80
+ continue
81
+ avg_conf = sum(v.confidence for v in group) / len(group)
82
+ avg_acc = sum(1 for v in group if self._is_correct(v)) / len(group)
83
+ errors.append(abs(avg_conf - avg_acc))
84
+
85
+ if not errors:
86
+ return 0.5
87
+
88
+ avg_error = sum(errors) / len(errors)
89
+ return max(0.0, 1.0 - 2.0 * avg_error)
graders/task3_grader.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task 3 Grader: Coordinated Fraud Network Detection (Hard).
3
+
4
+ Adds graph-based network detection scoring on top of verdict accuracy,
5
+ budget efficiency, and calibration. 20 ads, 35 budget, 3 fraud rings
6
+ with varied topologies. Expected baseline 0.1-0.3.
7
+
8
+ Network scoring uses ground truth edge coverage: what fraction of
9
+ ground-truth ring connections did the agent discover via link_accounts?
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .base_grader import BaseGrader, EpisodeRecord
15
+
16
+
17
+ class Task3Grader(BaseGrader):
18
+ def grade(self, record: EpisodeRecord) -> float:
19
+ raw = 0.0
20
+ best = 0.0
21
+ worst = 0.0
22
+
23
+ severity_map = {
24
+ m["ad_id"]: m.get("severity", 0.5) for m in record.ads_metadata
25
+ }
26
+
27
+ for v in record.verdicts:
28
+ severity = severity_map.get(v.ad_id, 0.5)
29
+ raw += self._verdict_reward(v, severity)
30
+
31
+ if v.ground_truth == "fraud":
32
+ best += 0.3 + 0.1 * severity
33
+ worst -= 0.5
34
+ elif v.ground_truth == "legit":
35
+ best += 0.1
36
+ worst -= 0.35
37
+ elif v.ground_truth == "escalate":
38
+ best += 0.15
39
+ worst -= 0.15
40
+
41
+ n_investigations = max(0, record.total_steps - len([
42
+ v for v in record.verdicts if not v.auto_approved
43
+ ]))
44
+ raw -= n_investigations * 0.02
45
+ worst -= record.action_budget * 0.02
46
+
47
+ # Budget efficiency bonus
48
+ best += 0.2
49
+ if record.total_steps > 0:
50
+ correct = self._count_correct_verdicts(record.verdicts)
51
+ raw += (correct / record.total_steps) * 0.2
52
+
53
+ # Calibration bonus
54
+ calibration = self._compute_calibration(record)
55
+ raw += calibration * 0.15
56
+ best += 0.15
57
+
58
+ # Graph-based network detection scoring
59
+ network_reward = self._compute_network_score(record)
60
+ raw += network_reward
61
+ # Best case: discover all ground truth edges
62
+ total_gt_edges = self._count_ground_truth_edges(record)
63
+ best += max(total_gt_edges, 1) * 0.4
64
+ worst -= max(len(record.links), 1) * 0.2
65
+
66
+ # Investigation coverage bonus: reward breadth over depth
67
+ coverage = self._compute_coverage_bonus(record)
68
+ raw += coverage * 0.1
69
+ best += 0.1
70
+
71
+ return self._normalize(raw, best, worst)
72
+
73
+ def _compute_calibration(self, record: EpisodeRecord) -> float:
74
+ manual = [v for v in record.verdicts if not v.auto_approved]
75
+ if len(manual) < 3:
76
+ return 0.5
77
+
78
+ bins = {"low": [], "mid": [], "high": []}
79
+ for v in manual:
80
+ if v.confidence < 0.4:
81
+ bins["low"].append(v)
82
+ elif v.confidence < 0.7:
83
+ bins["mid"].append(v)
84
+ else:
85
+ bins["high"].append(v)
86
+
87
+ errors = []
88
+ for _label, group in bins.items():
89
+ if not group:
90
+ continue
91
+ avg_conf = sum(v.confidence for v in group) / len(group)
92
+ avg_acc = sum(1 for v in group if self._is_correct(v)) / len(group)
93
+ errors.append(abs(avg_conf - avg_acc))
94
+
95
+ if not errors:
96
+ return 0.5
97
+
98
+ avg_error = sum(errors) / len(errors)
99
+ return max(0.0, 1.0 - 2.0 * avg_error)
100
+
101
+ def _count_ground_truth_edges(self, record: EpisodeRecord) -> int:
102
+ """Count the total number of ground truth edges across all rings."""
103
+ total = 0
104
+ for ring_size in record.ring_sizes:
105
+ total += ring_size * (ring_size - 1) // 2
106
+ return total
107
+
108
+ def _compute_network_score(self, record: EpisodeRecord) -> float:
109
+ """Score link_accounts actions against ground truth fraud rings.
110
+
111
+ Uses edge-coverage: correct links earn +0.4 each, incorrect
112
+ links incur -0.25 each (heavier penalty since decoys exist).
113
+ """
114
+ correct_links = sum(1 for l in record.links if l.correct)
115
+ incorrect_links = sum(1 for l in record.links if not l.correct)
116
+
117
+ reward = correct_links * 0.4
118
+ reward -= incorrect_links * 0.25
119
+
120
+ return reward
121
+
122
+ def _compute_coverage_bonus(self, record: EpisodeRecord) -> float:
123
+ """Reward agents that investigate across multiple ads (breadth).
124
+
125
+ Agents that only deep-dive a single ad miss network signals.
126
+ """
127
+ if not record.ads_metadata:
128
+ return 0.0
129
+
130
+ total_ads = len(record.ads_metadata)
131
+ reviewed = sum(1 for v in record.verdicts if not v.auto_approved)
132
+ coverage_ratio = reviewed / total_ads if total_ads > 0 else 0.0
133
+ return min(1.0, coverage_ratio)
inference.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference Script — Ad Fraud Investigation Environment
3
+ ===================================
4
+ MANDATORY
5
+ - Before submitting, ensure the following variables are defined in your environment configuration:
6
+ API_BASE_URL The API endpoint for the LLM.
7
+ MODEL_NAME The model identifier to use for inference.
8
+ HF_TOKEN Your Hugging Face / API key.
9
+ - The inference script must be named `inference.py` and placed in the root directory of the project
10
+ - Participants must use OpenAI Client for all LLM calls using above variables
11
+
12
+ STDOUT FORMAT
13
+ - The script must emit exactly three line types to stdout, in this order:
14
+
15
+ [START] task=<task_name> env=<benchmark> model=<model_name>
16
+ [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
17
+ [END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import re
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from openai import OpenAI
31
+
32
+ try:
33
+ from .client import AdFraudEnv
34
+ from .models import AdReviewAction
35
+ from .data.ad_generator import TASK_CONFIGS
36
+ except ImportError:
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
38
+ from ad_fraud_env.client import AdFraudEnv
39
+ from ad_fraud_env.models import AdReviewAction
40
+ from ad_fraud_env.data.ad_generator import TASK_CONFIGS
41
+
42
+ from dotenv import load_dotenv
43
+ load_dotenv()
44
+
45
+ API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
46
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
47
+ MODEL_NAME = os.getenv("MODEL_NAME") or "meta-llama/Llama-3.1-8B-Instruct"
48
+ ENV_URL = os.getenv("AD_FRAUD_ENV_URL", "https://QuantumTransformer-AdArena.hf.space")
49
+ BENCHMARK = "ad_fraud_env"
50
+ TEMPERATURE = 0.1
51
+ MAX_TOKENS = 256
52
+ FALLBACK_VERDICT = "escalate"
53
+
54
+ logger = logging.getLogger(__name__)
55
+ LOG_DIR = Path(__file__).resolve().parent / "convo_logging"
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Mandatory structured stdout logging
59
+ # ---------------------------------------------------------------------------
60
+
61
+ def log_start(task: str, env: str, model: str) -> None:
62
+ print(f"[START] task={task} env={env} model={model}", flush=True)
63
+
64
+
65
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
66
+ error_val = error if error else "null"
67
+ done_val = str(done).lower()
68
+ print(
69
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
70
+ f"done={done_val} error={error_val}",
71
+ flush=True,
72
+ )
73
+
74
+
75
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
76
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
77
+ print(
78
+ f"[END] success={str(success).lower()} steps={steps} "
79
+ f"score={score:.2f} rewards={rewards_str}",
80
+ flush=True,
81
+ )
82
+
83
+
84
+ def _format_action(action: AdReviewAction) -> str:
85
+ """Compact action string for [STEP] log line."""
86
+ if action.action_type == "investigate":
87
+ return f"investigate({action.ad_id},{action.investigation_target})"
88
+ elif action.action_type == "verdict":
89
+ conf = action.confidence if action.confidence is not None else 0.5
90
+ return f"verdict({action.ad_id},{action.verdict},{conf:.2f})"
91
+ elif action.action_type == "link_accounts":
92
+ return f"link_accounts({action.ad_id},{action.linked_ad_id})"
93
+ return f"unknown({action.ad_id})"
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # System prompt & helpers
97
+ # ---------------------------------------------------------------------------
98
+
99
+ SYSTEM_PROMPT = """\
100
+ You are an ad fraud investigator reviewing a queue of advertisements.
101
+ Your job is to investigate suspicious ads and render verdicts (approve, reject, or escalate).
102
+
103
+ For each step, you must output a single JSON action. The action schema is:
104
+
105
+ {
106
+ "action_type": "investigate" | "verdict" | "link_accounts",
107
+ "ad_id": "<ad ID, e.g. ad_001>",
108
+
109
+ // For investigate actions:
110
+ "investigation_target": "advertiser_history" | "landing_page" | "payment_method" | "targeting_overlap" | "creative_similarity" | "campaign_structure",
111
+
112
+ // For verdict actions:
113
+ "verdict": "approve" | "reject" | "escalate",
114
+ "confidence": <float 0.0-1.0>,
115
+
116
+ // For link_accounts actions:
117
+ "linked_ad_id": "<other ad ID>",
118
+ "link_reason": "<reason>"
119
+ }
120
+
121
+ Strategy:
122
+ 1. Start by reading the queue summary and the first ad's information.
123
+ 2. For obviously suspicious ads, investigate 1-2 signals then reject.
124
+ 3. For clearly legitimate ads, approve quickly with high confidence.
125
+ 4. For ambiguous ads, investigate more deeply before deciding.
126
+ 5. Manage your budget — you cannot investigate everything.
127
+ 6. For link_accounts, only flag connections when you see shared signals across ads (same payment method, similar creative template, targeting overlap).
128
+
129
+ Output ONLY the JSON action, no other text.
130
+ """
131
+
132
+ JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
133
+ _FINDING_BLOCK_RE = re.compile(r"\[(ad_\d+)\s*/\s*([a-z_]+)\]")
134
+ _ANALYSIS_HEADER_RE = re.compile(
135
+ r"^(?:Payment Method|Targeting|Creative|Campaign Structure) Analysis for ad_\d+:$"
136
+ )
137
+
138
+
139
+ def _extract_json(text: str) -> Dict[str, Any]:
140
+ """Extract JSON from LLM response, handling markdown code blocks."""
141
+ text = text.strip()
142
+ m = JSON_BLOCK_RE.search(text)
143
+ if m:
144
+ text = m.group(1).strip()
145
+ elif text.startswith("```"):
146
+ lines = text.split("\n")
147
+ lines = [l for l in lines if not l.strip().startswith("```")]
148
+ text = "\n".join(lines).strip()
149
+ return json.loads(text)
150
+
151
+
152
+ def _compact_finding(text: str) -> str:
153
+ """Compress one investigation finding block into a compact key-value line.
154
+
155
+ Strips analysis headers and section labels but preserves ALL data fields
156
+ (both signal and noise) so the agent must still reason about relevance.
157
+ """
158
+ parts: List[str] = []
159
+ for line in text.strip().split("\n"):
160
+ stripped = line.strip()
161
+ if not stripped:
162
+ continue
163
+ if _ANALYSIS_HEADER_RE.match(stripped):
164
+ continue
165
+ if stripped in ("Key claims on page:", "Suspicious elements:"):
166
+ continue
167
+ if stripped.startswith("- "):
168
+ stripped = stripped[2:]
169
+ parts.append(stripped)
170
+ return " | ".join(parts)
171
+
172
+
173
+ def _build_compact_findings(raw: str, focused_ad: Optional[str]) -> str:
174
+ """Parse findings blocks; full prose for focused ad, compact lines for others.
175
+
176
+ Each block in the raw findings string is delimited by [ad_XXX / target].
177
+ The focused ad keeps full investigation prose for deep reasoning.
178
+ Other ads are compressed to single-line key-value summaries that still
179
+ contain all extracted fields (payment type, IDs, template hashes,
180
+ fingerprints, domain ages, etc.) mixed with noise fields — the agent
181
+ must determine which values are meaningful for cross-ad comparison.
182
+ """
183
+ blocks: List[tuple] = []
184
+ current_ad: Optional[str] = None
185
+ current_header: Optional[str] = None
186
+ current_lines: List[str] = []
187
+
188
+ for line in raw.split("\n"):
189
+ m = _FINDING_BLOCK_RE.match(line.strip())
190
+ if m:
191
+ if current_ad is not None:
192
+ blocks.append((current_ad, current_header, "\n".join(current_lines)))
193
+ current_ad = m.group(1)
194
+ current_header = line.strip()
195
+ current_lines = []
196
+ else:
197
+ current_lines.append(line)
198
+
199
+ if current_ad is not None:
200
+ blocks.append((current_ad, current_header, "\n".join(current_lines)))
201
+
202
+ result: List[str] = []
203
+ for ad_id, header, text in blocks:
204
+ if focused_ad and ad_id == focused_ad:
205
+ result.append(f"\n{header}\n{text}")
206
+ else:
207
+ compact = _compact_finding(text)
208
+ if compact:
209
+ result.append(f"{header} {compact}")
210
+
211
+ return "\n".join(result)
212
+
213
+
214
+ def build_obs_prompt(obs: Any) -> str:
215
+ """Format an observation into the user prompt for the LLM.
216
+
217
+ The focused ad gets full investigation prose for deep reasoning.
218
+ Other investigated ads are compressed to compact key-value summaries
219
+ that preserve all fields (signal + noise) for cross-ad comparison.
220
+ """
221
+ focused_ad: Optional[str] = None
222
+ if obs.current_ad_info:
223
+ m = re.search(r"Ad in Focus:\s*(ad_\d+)", obs.current_ad_info)
224
+ if m:
225
+ focused_ad = m.group(1)
226
+
227
+ parts = [
228
+ f"Queue: {obs.queue_summary}",
229
+ f"Current Ad: {obs.current_ad_info}",
230
+ f"Feedback: {obs.feedback}",
231
+ f"Available ads: {', '.join(obs.available_ads)}",
232
+ ]
233
+ if obs.verdict_history_summary and obs.verdict_history_summary != "No verdicts yet.":
234
+ parts.append(f"Verdicts: {obs.verdict_history_summary}")
235
+ if obs.investigation_findings:
236
+ findings = _build_compact_findings(obs.investigation_findings, focused_ad)
237
+ if findings:
238
+ parts.append(f"Findings:\n{findings}")
239
+ return "\n\n".join(parts)
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Episode logger (markdown, for debugging — separate from mandatory stdout)
243
+ # ---------------------------------------------------------------------------
244
+
245
+ class EpisodeLogger:
246
+ """Logs the full agent-environment conversation to a markdown file."""
247
+
248
+ def __init__(self, task_id: str, log_dir: Path) -> None:
249
+ self.task_id = task_id
250
+ self.lines: List[str] = []
251
+ log_dir.mkdir(parents=True, exist_ok=True)
252
+ self.path = log_dir / f"{task_id}_conversation.md"
253
+ self._md(f"# Episode Log — {task_id}\n")
254
+
255
+ def step_start(self, step: int, obs_prompt: str) -> None:
256
+ self._md(f"\n## Step {step}\n")
257
+ self._md(f"### Observation (sent to LLM)\n```\n{obs_prompt}\n```\n")
258
+
259
+ def llm_response(self, step: int, raw: str, action: AdReviewAction | None, fallback: bool) -> None:
260
+ tag = " [FALLBACK]" if fallback else ""
261
+ self._md(f"### LLM Response\n```json\n{raw.strip()}\n```\n")
262
+ if action:
263
+ act_dict = action.model_dump(exclude_none=True, exclude={"metadata"})
264
+ self._md(f"### Parsed Action{tag}\n```json\n{json.dumps(act_dict, indent=2)}\n```\n")
265
+
266
+ def env_feedback(self, step: int, reward: float, done: bool, feedback: str) -> None:
267
+ self._md(f"### Environment Response\n- **Reward:** `{reward:+.2f}`\n- **Done:** `{done}`\n")
268
+ self._md(f"- **Feedback:** {feedback}\n")
269
+
270
+ def episode_end(self, score: float, steps: int, verdicts: int, total: int) -> None:
271
+ summary = f"Score: {score:.3f} | Steps: {steps} | Verdicts: {verdicts}/{total}"
272
+ self._md(f"\n---\n## Result\n**{summary}**\n")
273
+ self._flush()
274
+
275
+ def _md(self, text: str) -> None:
276
+ self.lines.append(text)
277
+
278
+ def _flush(self) -> None:
279
+ with open(self.path, "w", encoding="utf-8") as f:
280
+ f.write("\n".join(self.lines))
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # Core task runner
284
+ # ---------------------------------------------------------------------------
285
+
286
+ def run_single_task(
287
+ task_id: str,
288
+ seed: int = 42,
289
+ env_base_url: str = "https://QuantumTransformer-AdArena.hf.space",
290
+ ) -> Dict[str, Any]:
291
+ """Run the baseline agent on a single task with mandatory [START]/[STEP]/[END] logging."""
292
+ base_url = os.getenv("API_BASE_URL") or API_BASE_URL
293
+ api_key = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or API_KEY
294
+ model = os.getenv("MODEL_NAME") or MODEL_NAME
295
+
296
+ client = OpenAI(base_url=base_url, api_key=api_key, timeout=60.0)
297
+ env = AdFraudEnv(base_url=env_base_url).sync()
298
+ elog = EpisodeLogger(task_id, LOG_DIR)
299
+
300
+ config = TASK_CONFIGS.get(task_id)
301
+ max_steps = config.action_budget if config else 25
302
+
303
+ rewards: List[float] = []
304
+ steps_taken = 0
305
+ score = 0.0
306
+ success = False
307
+
308
+ log_start(task=task_id, env=BENCHMARK, model=model)
309
+
310
+ try:
311
+ env.connect()
312
+ result = env.reset(seed=seed, task_id=task_id)
313
+
314
+ messages: List[Dict[str, str]] = [
315
+ {"role": "system", "content": SYSTEM_PROMPT},
316
+ ]
317
+
318
+ while not result.done and steps_taken < max_steps:
319
+ obs = result.observation
320
+ user_prompt = build_obs_prompt(obs)
321
+ messages.append({"role": "user", "content": user_prompt})
322
+
323
+ elog.step_start(steps_taken, user_prompt)
324
+
325
+ try:
326
+ completion = client.chat.completions.create(
327
+ model=model,
328
+ messages=messages,
329
+ temperature=TEMPERATURE,
330
+ max_tokens=MAX_TOKENS,
331
+ stream=False,
332
+ )
333
+ response_text = completion.choices[0].message.content or "{}"
334
+ except Exception as exc:
335
+ logger.warning("Model request failed on step %d: %s", steps_taken, exc)
336
+ response_text = "{}"
337
+
338
+ messages.append({"role": "assistant", "content": response_text})
339
+
340
+ error_msg = None
341
+ fallback = False
342
+ try:
343
+ action_data = _extract_json(response_text)
344
+ action = AdReviewAction(**action_data)
345
+ except Exception as e:
346
+ logger.warning("Failed to parse action on step %d: %s", steps_taken, e)
347
+ fallback = True
348
+ error_msg = str(e)
349
+ if obs.available_ads:
350
+ action = AdReviewAction(
351
+ action_type="verdict",
352
+ ad_id=obs.available_ads[0],
353
+ verdict=FALLBACK_VERDICT,
354
+ confidence=0.3,
355
+ )
356
+ else:
357
+ elog.llm_response(steps_taken, response_text, None, True)
358
+ break
359
+
360
+ elog.llm_response(steps_taken, response_text, action, fallback)
361
+
362
+ result = env.step(action)
363
+ steps_taken += 1
364
+ reward = result.reward or 0.0
365
+ rewards.append(reward)
366
+
367
+ log_step(
368
+ step=steps_taken,
369
+ action=_format_action(action),
370
+ reward=reward,
371
+ done=result.done,
372
+ error=error_msg,
373
+ )
374
+
375
+ elog.env_feedback(steps_taken, reward, result.done, result.observation.feedback)
376
+
377
+ # Each observation is self-contained (all findings + verdict summary),
378
+ # so we only keep system prompt + last 2 exchanges to stay within
379
+ # context limits while preserving the agent's reasoning continuity.
380
+ if len(messages) > 6:
381
+ messages = messages[:1] + messages[-4:]
382
+
383
+ state = env.state()
384
+ score = state.grader_score if state.grader_score is not None else 0.0
385
+ score = max(0.0, min(1.0, score))
386
+ success = score > 0.0
387
+
388
+ elog.episode_end(score, steps_taken, state.reviewed_count, state.total_ads)
389
+
390
+ return {
391
+ "task_id": task_id,
392
+ "score": score,
393
+ "steps": steps_taken,
394
+ "verdicts": state.reviewed_count,
395
+ "total_ads": state.total_ads,
396
+ }
397
+
398
+ except Exception as e:
399
+ logger.error("Task %s failed: %s", task_id, e)
400
+ return {"task_id": task_id, "score": 0.0, "error": str(e)}
401
+
402
+ finally:
403
+ try:
404
+ env.close()
405
+ except Exception as e:
406
+ print(f"[DEBUG] env.close() error: {e}", flush=True)
407
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
408
+
409
+
410
+ def run_baseline(
411
+ env_base_url: str = "https://QuantumTransformer-AdArena.hf.space",
412
+ ) -> Dict[str, Any]:
413
+ """Run baseline inference on all 3 tasks."""
414
+ model = os.getenv("MODEL_NAME") or MODEL_NAME
415
+ results: Dict[str, Any] = {}
416
+ for task_id in ["task_1", "task_2", "task_3"]:
417
+ logger.info("Running baseline for %s...", task_id)
418
+ try:
419
+ task_result = run_single_task(
420
+ task_id, seed=42, env_base_url=env_base_url,
421
+ )
422
+ results[task_id] = task_result
423
+ logger.info(" %s score: %.3f", task_id, task_result["score"])
424
+ except Exception as e:
425
+ logger.error(" %s failed: %s", task_id, e)
426
+ results[task_id] = {"task_id": task_id, "score": 0.0, "error": str(e)}
427
+
428
+ return {"baseline_model": model, "seed": 42, "tasks": results}
429
+
430
+
431
+ def main() -> None:
432
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
433
+
434
+ if not API_KEY:
435
+ print("Error: HF_TOKEN (or API_KEY) environment variable is required.", file=sys.stderr)
436
+ sys.exit(1)
437
+ env_base_url = os.getenv("AD_FRAUD_ENV_URL", "https://QuantumTransformer-AdArena.hf.space")
438
+
439
+ print(f"Running baseline inference against {env_base_url} with model {MODEL_NAME}...", file=sys.stderr)
440
+ scores = run_baseline(env_base_url=env_base_url)
441
+
442
+ output_path = Path(__file__).resolve().parent / "baseline_scores.json"
443
+ with open(output_path, "w") as f:
444
+ json.dump(scores, f, indent=2)
445
+
446
+ print(f"\nBaseline scores saved to {output_path}", file=sys.stderr)
447
+ print(json.dumps(scores, indent=2), file=sys.stderr)
448
+
449
+
450
+ if __name__ == "__main__":
451
+ main()
models.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the Ad Fraud Investigation Environment.
3
+
4
+ Defines the Action, Observation, and State types used by the OpenEnv interface.
5
+ The agent interacts with a queue of ads, investigating them and rendering verdicts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List, Literal, Optional
11
+
12
+ from openenv.core.env_server.types import Action, Observation, State
13
+ from pydantic import Field
14
+
15
+
16
+ class AdReviewAction(Action):
17
+ """
18
+ Action space for the ad fraud investigation agent.
19
+
20
+ Three action types:
21
+ - investigate: Spend budget to reveal information about an ad
22
+ - verdict: Approve, reject, or escalate an ad
23
+ - link_accounts: Flag two ads as part of the same fraud network
24
+ """
25
+
26
+ action_type: Literal["investigate", "verdict", "link_accounts"]
27
+ ad_id: str = Field(..., description="Target ad identifier (e.g. 'ad_001')")
28
+
29
+ investigation_target: Optional[
30
+ Literal[
31
+ "advertiser_history",
32
+ "landing_page",
33
+ "payment_method",
34
+ "targeting_overlap",
35
+ "creative_similarity",
36
+ "campaign_structure",
37
+ ]
38
+ ] = Field(None, description="What to investigate (required for action_type='investigate')")
39
+
40
+ verdict: Optional[Literal["approve", "reject", "escalate"]] = Field(
41
+ None, description="Verdict decision (required for action_type='verdict')"
42
+ )
43
+ confidence: Optional[float] = Field(
44
+ None, ge=0.0, le=1.0, description="Agent's confidence in verdict (0.0-1.0)"
45
+ )
46
+
47
+ linked_ad_id: Optional[str] = Field(
48
+ None, description="Other ad in suspected fraud ring (required for action_type='link_accounts')"
49
+ )
50
+ link_reason: Optional[str] = Field(
51
+ None, description="Why the agent believes these ads are connected"
52
+ )
53
+
54
+
55
+ class AdReviewObservation(Observation):
56
+ """
57
+ Observation returned after each step.
58
+
59
+ Text-heavy by design so LLM agents can reason about the content naturally.
60
+ Structured data is in queue_status for programmatic access.
61
+ """
62
+
63
+ queue_summary: str = Field(
64
+ default="", description="Natural language overview of the ad queue"
65
+ )
66
+ current_ad_info: str = Field(
67
+ default="", description="Details of the ad currently in focus"
68
+ )
69
+ investigation_findings: str = Field(
70
+ default="", description="Accumulated investigation results"
71
+ )
72
+ verdict_history_summary: str = Field(
73
+ default="", description="Summary of verdicts rendered so far"
74
+ )
75
+ feedback: str = Field(
76
+ default="", description="Natural language feedback on the last action taken"
77
+ )
78
+ available_ads: List[str] = Field(
79
+ default_factory=list, description="Ad IDs still pending review"
80
+ )
81
+ queue_status: Dict = Field(
82
+ default_factory=dict,
83
+ description="Structured status: total_ads, reviewed, pending, budget, step",
84
+ )
85
+
86
+
87
+ class AdFraudState(State):
88
+ """
89
+ Internal environment state exposed via the state() property.
90
+
91
+ Inherits episode_id and step_count from State.
92
+ Uses extra='allow' so custom fields are permitted.
93
+ """
94
+
95
+ task_id: str = ""
96
+ total_ads: int = 0
97
+ reviewed_count: int = 0
98
+ remaining_budget: int = 0
99
+ verdicts: Dict = Field(default_factory=dict)
100
+ grader_score: Optional[float] = None
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: ad_fraud_env
3
+ type: space
4
+ sdk: docker
5
+ runtime: fastapi
6
+ app: server.app:app
7
+ port: 8000
openenv_ad_fraud_env.egg-info/PKG-INFO ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-ad-fraud-env
3
+ Version: 0.1.0
4
+ Summary: Ad Fraud Investigation Environment for OpenEnv — real-world ad fraud detection and investigation simulation
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: openenv-core[core]>=0.2.2
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: pydantic>=2.0.0
9
+ Requires-Dist: uvicorn[standard]>=0.24.0
10
+ Requires-Dist: requests>=2.31.0
11
+ Requires-Dist: faker==33.1.0
12
+ Requires-Dist: networkx>=3.2
13
+ Requires-Dist: openai>=1.0.0
14
+ Requires-Dist: python-dotenv>=1.0.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
17
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
18
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
openenv_ad_fraud_env.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ __init__.py
3
+ client.py
4
+ inference.py
5
+ models.py
6
+ pyproject.toml
7
+ ./__init__.py
8
+ ./client.py
9
+ ./inference.py
10
+ ./models.py
11
+ data/__init__.py
12
+ data/ad_generator.py
13
+ data/advertiser_profiles.py
14
+ data/fraud_patterns.py
15
+ data/landing_pages.py
16
+ data/network_generator.py
17
+ graders/__init__.py
18
+ graders/base_grader.py
19
+ graders/task1_grader.py
20
+ graders/task2_grader.py
21
+ graders/task3_grader.py
22
+ openenv_ad_fraud_env.egg-info/PKG-INFO
23
+ openenv_ad_fraud_env.egg-info/SOURCES.txt
24
+ openenv_ad_fraud_env.egg-info/dependency_links.txt
25
+ openenv_ad_fraud_env.egg-info/entry_points.txt
26
+ openenv_ad_fraud_env.egg-info/requires.txt
27
+ openenv_ad_fraud_env.egg-info/top_level.txt
28
+ server/__init__.py
29
+ server/app.py
30
+ server/environment.py
31
+ tests/test_data_generation.py
32
+ tests/test_environment.py
33
+ tests/test_graders.py
openenv_ad_fraud_env.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
openenv_ad_fraud_env.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = ad_fraud_env.server.app:main
openenv_ad_fraud_env.egg-info/requires.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ fastapi>=0.115.0
3
+ pydantic>=2.0.0
4
+ uvicorn[standard]>=0.24.0
5
+ requests>=2.31.0
6
+ faker==33.1.0
7
+ networkx>=3.2
8
+ openai>=1.0.0
9
+ python-dotenv>=1.0.0
10
+
11
+ [dev]
12
+ pytest>=8.0.0
13
+ pytest-cov>=4.0.0
14
+ pytest-asyncio>=0.23.0
openenv_ad_fraud_env.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ad_fraud_env
outputs/task_1_conversation.md ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Episode Log — task_1
2
+
3
+
4
+ ## Step 0
5
+
6
+ ### Observation (sent to LLM)
7
+ ```
8
+ Queue: Task: Basic Ad Triage (easy)
9
+ Total ads: 5 | Reviewed: 0 | Pending: 5 | Budget remaining: 25 actions | Step: 0
10
+
11
+ Current Ad: === Ad in Focus: ad_001 ===
12
+ Category: local_service
13
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
14
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
15
+ Initial risk signals: None
16
+ Investigations completed: None yet
17
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
18
+
19
+ Feedback: Episode started. Review the ad queue and begin your investigation.
20
+
21
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005
22
+ ```
23
+
24
+ ### LLM Response
25
+ ```json
26
+ {
27
+ "action_type": "investigate",
28
+ "ad_id": "ad_001",
29
+ "investigation_target": "landing_page"
30
+ }
31
+ ```
32
+
33
+ ### Parsed Action
34
+ ```json
35
+ {
36
+ "action_type": "investigate",
37
+ "ad_id": "ad_001",
38
+ "investigation_target": "landing_page"
39
+ }
40
+ ```
41
+
42
+ ### Environment Response
43
+ - **Reward:** `+0.00`
44
+ - **Done:** `False`
45
+
46
+ - **Feedback:** Investigation complete: landing_page for ad_001.
47
+ --- Findings ---
48
+ Domain: nutri.com
49
+ Domain age: 1672 days
50
+ SSL: Valid SSL certificate
51
+ Registrar: Cloudflare Registrar
52
+ Redirect chain: No redirects (direct)
53
+ Similarity to known scam templates: 5%
54
+ Key claims on page:
55
+ - Licensed and insured
56
+ - Established since 2010
57
+ - BBB accredited
58
+ Suspicious elements:
59
+ None detected
60
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
61
+
62
+
63
+ ## Step 1
64
+
65
+ ### Observation (sent to LLM)
66
+ ```
67
+ Queue: Task: Basic Ad Triage (easy)
68
+ Total ads: 5 | Reviewed: 0 | Pending: 5 | Budget remaining: 24 actions | Step: 1
69
+
70
+ Current Ad: === Ad in Focus: ad_001 ===
71
+ Category: local_service
72
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
73
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
74
+ Initial risk signals: None
75
+ Investigations completed: landing_page
76
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
77
+
78
+ Feedback: Investigation complete: landing_page for ad_001.
79
+ --- Findings ---
80
+ Domain: nutri.com
81
+ Domain age: 1672 days
82
+ SSL: Valid SSL certificate
83
+ Registrar: Cloudflare Registrar
84
+ Redirect chain: No redirects (direct)
85
+ Similarity to known scam templates: 5%
86
+ Key claims on page:
87
+ - Licensed and insured
88
+ - Established since 2010
89
+ - BBB accredited
90
+ Suspicious elements:
91
+ None detected
92
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
93
+
94
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005
95
+
96
+ Findings:
97
+ [ad_001 / landing_page]
98
+ Domain: nutri.com
99
+ Domain age: 1672 days
100
+ SSL: Valid SSL certificate
101
+ Registrar: Cloudflare Registrar
102
+ Redirect chain: No redirects (direct)
103
+ Similarity to known scam templates: 5%
104
+ Key claims on page:
105
+ - Licensed and insured
106
+ - Established since 2010
107
+ - BBB accredited
108
+ Suspicious elements:
109
+ None detected
110
+ Content summary: E-commerce store with detailed product descrip
111
+ ```
112
+
113
+ ### LLM Response
114
+ ```json
115
+ {
116
+ "action_type": "verdict",
117
+ "ad_id": "ad_001",
118
+ "verdict": "approve",
119
+ "confidence": 0.9
120
+ }
121
+ ```
122
+
123
+ ### Parsed Action
124
+ ```json
125
+ {
126
+ "action_type": "verdict",
127
+ "ad_id": "ad_001",
128
+ "verdict": "approve",
129
+ "confidence": 0.9
130
+ }
131
+ ```
132
+
133
+ ### Environment Response
134
+ - **Reward:** `+0.00`
135
+ - **Done:** `False`
136
+
137
+ - **Feedback:** Verdict recorded for ad_001: approve (confidence: 0.90). Correct decision. Reward: +0.10
138
+
139
+
140
+ ## Step 2
141
+
142
+ ### Observation (sent to LLM)
143
+ ```
144
+ Queue: Task: Basic Ad Triage (easy)
145
+ Total ads: 5 | Reviewed: 1 | Pending: 4 | Budget remaining: 24 actions | Step: 2
146
+
147
+ Current Ad: === Ad in Focus: ad_002 ===
148
+ Category: counterfeit_goods
149
+ Ad copy: "Rolex watches starting at $49.99! 100% genuine! Free worldwide shipping! LuxWatchDeals.net"
150
+ Targeting: Men 25-55, interests: watches, luxury
151
+ Initial risk signals: impossibly low prices for luxury goods, unfamiliar domain, claims of authenticity with extreme discounts
152
+ Investigations completed: None yet
153
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
154
+
155
+ Feedback: Verdict recorded for ad_001: approve (confidence: 0.90). Correct decision. Reward: +0.10
156
+
157
+ Available ads: ad_002, ad_003, ad_004, ad_005
158
+
159
+ Verdicts: ad_001: approve (confidence: 0.90)
160
+
161
+ Findings:
162
+ [ad_001 / landing_page]
163
+ Domain: nutri.com
164
+ Domain age: 1672 days
165
+ SSL: Valid SSL certificate
166
+ Registrar: Cloudflare Registrar
167
+ Redirect chain: No redirects (direct)
168
+ Similarity to known scam templates: 5%
169
+ Key claims on page:
170
+ - Licensed and insured
171
+ - Established since 2010
172
+ - BBB accredited
173
+ Suspicious elements:
174
+ None detected
175
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
176
+ ```
177
+
178
+ ### LLM Response
179
+ ```json
180
+ {
181
+ "action_type": "investigate",
182
+ "ad_id": "ad_002",
183
+ "investigation_target": "landing_page"
184
+ }
185
+ ```
186
+
187
+ ### Parsed Action
188
+ ```json
189
+ {
190
+ "action_type": "investigate",
191
+ "ad_id": "ad_002",
192
+ "investigation_target": "landing_page"
193
+ }
194
+ ```
195
+
196
+ ### Environment Response
197
+ - **Reward:** `+0.00`
198
+ - **Done:** `False`
199
+
200
+ - **Feedback:** Investigation complete: landing_page for ad_002.
201
+ --- Findings ---
202
+ Domain: store4751.site
203
+ Domain age: 6 days
204
+ SSL: NO SSL / expired certificate
205
+ Registrar: NameSilo
206
+ Redirect chain: store4751.site -> redirect651..top -> redirect167..top -> redirect364..click -> redirect687..top -> final-719..site
207
+ Similarity to known scam templates: 67%
208
+ Key claims on page:
209
+ - Authentic products
210
+ - Direct from manufacturer
211
+ - 90% below retail price
212
+ Suspicious elements:
213
+ - Privacy policy copied from template
214
+ - Terms of service link returns 404
215
+ - No physical address listed
216
+ - Price comparison shows 90%+ discount vs retail
217
+ - No contact phone number
218
+ Content summary: Product catalog showing luxury branded items at extreme discounts. Stock photos. Payment accepted via wire transfer and crypto only.
219
+
220
+
221
+ ## Step 3
222
+
223
+ ### Observation (sent to LLM)
224
+ ```
225
+ Queue: Task: Basic Ad Triage (easy)
226
+ Total ads: 5 | Reviewed: 1 | Pending: 4 | Budget remaining: 23 actions | Step: 3
227
+
228
+ Current Ad: === Ad in Focus: ad_002 ===
229
+ Category: counterfeit_goods
230
+ Ad copy: "Rolex watches starting at $49.99! 100% genuine! Free worldwide shipping! LuxWatchDeals.net"
231
+ Targeting: Men 25-55, interests: watches, luxury
232
+ Initial risk signals: impossibly low prices for luxury goods, unfamiliar domain, claims of authenticity with extreme discounts
233
+ Investigations completed: landing_page
234
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
235
+
236
+ Feedback: Investigation complete: landing_page for ad_002.
237
+ --- Findings ---
238
+ Domain: store4751.site
239
+ Domain age: 6 days
240
+ SSL: NO SSL / expired certificate
241
+ Registrar: NameSilo
242
+ Redirect chain: store4751.site -> redirect651..top -> redirect167..top -> redirect364..click -> redirect687..top -> final-719..site
243
+ Similarity to known scam templates: 67%
244
+ Key claims on page:
245
+ - Authentic products
246
+ - Direct from manufacturer
247
+ - 90% below retail price
248
+ Suspicious elements:
249
+ - Privacy policy copied from template
250
+ - Terms of service link returns 404
251
+ - No physical address listed
252
+ - Price comparison shows 90%+ disco... [truncated]
253
+
254
+ Available ads: ad_002, ad_003, ad_004, ad_005
255
+
256
+ Verdicts: ad_001: approve (confidence: 0.90)
257
+
258
+ Findings:
259
+ [ad_001 / landing_page]
260
+ Domain: nutri.com
261
+ Domain age: 1672 days
262
+ SSL: Valid SSL certificate
263
+ Registrar: Cloudflare Registrar
264
+ Redirect chain: No
265
+ ```
266
+
267
+ ### LLM Response
268
+ ```json
269
+ {
270
+ "action_type": "verdict",
271
+ "ad_id": "ad_002",
272
+ "verdict": "reject",
273
+ "confidence": 0.95
274
+ }
275
+ ```
276
+
277
+ ### Parsed Action
278
+ ```json
279
+ {
280
+ "action_type": "verdict",
281
+ "ad_id": "ad_002",
282
+ "verdict": "reject",
283
+ "confidence": 0.95
284
+ }
285
+ ```
286
+
287
+ ### Environment Response
288
+ - **Reward:** `+0.00`
289
+ - **Done:** `False`
290
+
291
+ - **Feedback:** Verdict recorded for ad_002: reject (confidence: 0.95). Correct decision. Reward: +0.38
292
+
293
+
294
+ ## Step 4
295
+
296
+ ### Observation (sent to LLM)
297
+ ```
298
+ Queue: Task: Basic Ad Triage (easy)
299
+ Total ads: 5 | Reviewed: 2 | Pending: 3 | Budget remaining: 23 actions | Step: 4
300
+
301
+ Current Ad: === Ad in Focus: ad_003 ===
302
+ Category: fake_giveaway
303
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
304
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
305
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
306
+ Investigations completed: None yet
307
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
308
+
309
+ Feedback: Verdict recorded for ad_002: reject (confidence: 0.95). Correct decision. Reward: +0.38
310
+
311
+ Available ads: ad_003, ad_004, ad_005
312
+
313
+ Verdicts: ad_001: approve (confidence: 0.90)
314
+ ad_002: reject (confidence: 0.95)
315
+
316
+ Findings:
317
+ [ad_001 / landing_page]
318
+ Domain: nutri.com
319
+ Domain age: 1672 days
320
+ SSL: Valid SSL certificate
321
+ Registrar: Cloudflare Registrar
322
+ Redirect chain: No redirects (direct)
323
+ Similarity to known scam templates: 5%
324
+ Key claims on page:
325
+ - Licensed and insured
326
+ - Established since 2010
327
+ - BBB accredited
328
+ Suspicious elements:
329
+ None detected
330
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
331
+
332
+ [ad_002 / landing_page]
333
+ Domain: store4751.site
334
+ Domain age: 6 days
335
+ SSL: NO SSL / expired certificate
336
+ Registrar: NameSilo
337
+ Redirect chain: store4751.... [truncated]
338
+ ```
339
+
340
+ ### LLM Response
341
+ ```json
342
+ {
343
+ "action_type": "investigate",
344
+ "ad_id": "ad_003",
345
+ "investigation_target": "landing_page"
346
+ }
347
+ ```
348
+
349
+ ### Parsed Action
350
+ ```json
351
+ {
352
+ "action_type": "investigate",
353
+ "ad_id": "ad_003",
354
+ "investigation_target": "landing_page"
355
+ }
356
+ ```
357
+
358
+ ### Environment Response
359
+ - **Reward:** `+0.00`
360
+ - **Done:** `False`
361
+
362
+ - **Feedback:** Investigation complete: landing_page for ad_003.
363
+ --- Findings ---
364
+ Domain: get8189.store
365
+ Domain age: 26 days
366
+ SSL: NO SSL / expired certificate
367
+ Registrar: Tucows (privacy proxy)
368
+ Redirect chain: get8189.store -> redirect253..xyz -> redirect575..xyz -> final-270..store
369
+ Similarity to known scam templates: 56%
370
+ Key claims on page:
371
+ - 100% free, no purchase necessary
372
+ - Guaranteed winner
373
+ - Act now — expires in 2 hours
374
+ Suspicious elements:
375
+ - No contact phone number
376
+ - Requests personal info before any value provided
377
+ - No physical address listed
378
+ - Privacy policy copied from template
379
+ - Terms of service link returns 404
380
+ Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
381
+
382
+
383
+ ## Step 5
384
+
385
+ ### Observation (sent to LLM)
386
+ ```
387
+ Queue: Task: Basic Ad Triage (easy)
388
+ Total ads: 5 | Reviewed: 2 | Pending: 3 | Budget remaining: 22 actions | Step: 5
389
+
390
+ Current Ad: === Ad in Focus: ad_003 ===
391
+ Category: fake_giveaway
392
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
393
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
394
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
395
+ Investigations completed: landing_page
396
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
397
+
398
+ Feedback: Investigation complete: landing_page for ad_003.
399
+ --- Findings ---
400
+ Domain: get8189.store
401
+ Domain age: 26 days
402
+ SSL: NO SSL / expired certificate
403
+ Registrar: Tucows (privacy proxy)
404
+ Redirect chain: get8189.store -> redirect253..xyz -> redirect575..xyz -> final-270..store
405
+ Similarity to known scam templates: 56%
406
+ Key claims on page:
407
+ - 100% free, no purchase necessary
408
+ - Guaranteed winner
409
+ - Act now — expires in 2 hours
410
+ Suspicious elements:
411
+ - No contact phone number
412
+ - Requests personal info before any value provided
413
+ - No physical address listed
414
+ - Privacy policy copied from template
415
+ - Terms... [truncated]
416
+
417
+ Available ads: ad_003, ad_004, ad_005
418
+
419
+ Verdicts: ad_001: approve (confidence: 0.90)
420
+ ad_002: reject (confidence: 0.95)
421
+
422
+ Findings:
423
+ [ad_001 / landing_page]
424
+ Domain: nutri.com
425
+ Domain age: 1672 days
426
+ SSL: Valid SSL certificate
427
+ Registrar: Clou
428
+ ```
429
+
430
+ ### LLM Response
431
+ ```json
432
+ {
433
+ "action_type": "verdict",
434
+ "ad_id": "ad_003",
435
+ "verdict": "reject",
436
+ "confidence": 0.98
437
+ }
438
+ ```
439
+
440
+ ### Parsed Action
441
+ ```json
442
+ {
443
+ "action_type": "verdict",
444
+ "ad_id": "ad_003",
445
+ "verdict": "reject",
446
+ "confidence": 0.98
447
+ }
448
+ ```
449
+
450
+ ### Environment Response
451
+ - **Reward:** `+0.00`
452
+ - **Done:** `False`
453
+
454
+ - **Feedback:** Verdict recorded for ad_003: reject (confidence: 0.98). Correct decision. Reward: +0.39
455
+
456
+
457
+ ## Step 6
458
+
459
+ ### Observation (sent to LLM)
460
+ ```
461
+ Queue: Task: Basic Ad Triage (easy)
462
+ Total ads: 5 | Reviewed: 3 | Pending: 2 | Budget remaining: 22 actions | Step: 6
463
+
464
+ Current Ad: === Ad in Focus: ad_004 ===
465
+ Category: ecommerce
466
+ Ad copy: "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest."
467
+ Targeting: Women 25-54, interests: home decor, shopping
468
+ Initial risk signals: None
469
+ Investigations completed: None yet
470
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
471
+
472
+ Feedback: Verdict recorded for ad_003: reject (confidence: 0.98). Correct decision. Reward: +0.39
473
+
474
+ Available ads: ad_004, ad_005
475
+
476
+ Verdicts: ad_001: approve (confidence: 0.90)
477
+ ad_002: reject (confidence: 0.95)
478
+ ad_003: reject (confidence: 0.98)
479
+
480
+ Findings:
481
+ [ad_001 / landing_page]
482
+ Domain: nutri.com
483
+ Domain age: 1672 days
484
+ SSL: Valid SSL certificate
485
+ Registrar: Cloudflare Registrar
486
+ Redirect chain: No redirects (direct)
487
+ Similarity to known scam templates: 5%
488
+ Key claims on page:
489
+ - Licensed and insured
490
+ - Established since 2010
491
+ - BBB accredited
492
+ Suspicious elements:
493
+ None detected
494
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
495
+
496
+ [ad_002 / landing_page]
497
+ Domain: store4751.site
498
+ Domain age: 6 days
499
+ SSL: NO SSL / expired certificate
500
+ Registrar: NameSilo
501
+ Redirect chain: store4751.... [truncated]
502
+ ```
503
+
504
+ ### LLM Response
505
+ ```json
506
+ {
507
+ "action_type": "investigate",
508
+ "ad_id": "ad_004",
509
+ "investigation_target": "landing_page"
510
+ }
511
+ ```
512
+
513
+ ### Parsed Action
514
+ ```json
515
+ {
516
+ "action_type": "investigate",
517
+ "ad_id": "ad_004",
518
+ "investigation_target": "landing_page"
519
+ }
520
+ ```
521
+
522
+ ### Environment Response
523
+ - **Reward:** `+0.00`
524
+ - **Done:** `False`
525
+
526
+ - **Feedback:** Investigation complete: landing_page for ad_004.
527
+ --- Findings ---
528
+ Domain: sparkle.io
529
+ Domain age: 2424 days
530
+ SSL: Valid SSL certificate
531
+ Registrar: AWS Route 53
532
+ Redirect chain: No redirects (direct)
533
+ Similarity to known scam templates: 13%
534
+ Key claims on page:
535
+ - 4.5-star average customer rating
536
+ - 14-day free trial
537
+ Suspicious elements:
538
+ None detected
539
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
540
+
541
+
542
+ ## Step 7
543
+
544
+ ### Observation (sent to LLM)
545
+ ```
546
+ Queue: Task: Basic Ad Triage (easy)
547
+ Total ads: 5 | Reviewed: 3 | Pending: 2 | Budget remaining: 21 actions | Step: 7
548
+
549
+ Current Ad: === Ad in Focus: ad_004 ===
550
+ Category: ecommerce
551
+ Ad copy: "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest."
552
+ Targeting: Women 25-54, interests: home decor, shopping
553
+ Initial risk signals: None
554
+ Investigations completed: landing_page
555
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
556
+
557
+ Feedback: Investigation complete: landing_page for ad_004.
558
+ --- Findings ---
559
+ Domain: sparkle.io
560
+ Domain age: 2424 days
561
+ SSL: Valid SSL certificate
562
+ Registrar: AWS Route 53
563
+ Redirect chain: No redirects (direct)
564
+ Similarity to known scam templates: 13%
565
+ Key claims on page:
566
+ - 4.5-star average customer rating
567
+ - 14-day free trial
568
+ Suspicious elements:
569
+ None detected
570
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
571
+
572
+ Available ads: ad_004, ad_005
573
+
574
+ Verdicts: ad_001: approve (confidence: 0.90)
575
+ ad_002: reject (confidence: 0.95)
576
+ ad_003: reject (confidence: 0.98)
577
+
578
+ Findings:
579
+ [ad_001 / landing_page]
580
+ Domain: nutri.com
581
+ Domain age: 1672 days
582
+ SSL: Valid SSL certificate
583
+ Registrar: Cloudflare Registrar
584
+ Redirect chain: No redirects (direct)
585
+ Similarity to known scam templates: 5%
586
+ Key claims on page:
587
+ - Licensed and insured
588
+ - Established since 2010
589
+ - BBB accredited
590
+ Suspicious elements:
591
+ No
592
+ ```
593
+
594
+ ### LLM Response
595
+ ```json
596
+ {
597
+ "action_type": "verdict",
598
+ "ad_id": "ad_004",
599
+ "verdict": "approve",
600
+ "confidence": 0.92
601
+ }
602
+ ```
603
+
604
+ ### Parsed Action
605
+ ```json
606
+ {
607
+ "action_type": "verdict",
608
+ "ad_id": "ad_004",
609
+ "verdict": "approve",
610
+ "confidence": 0.92
611
+ }
612
+ ```
613
+
614
+ ### Environment Response
615
+ - **Reward:** `+0.00`
616
+ - **Done:** `False`
617
+
618
+ - **Feedback:** Verdict recorded for ad_004: approve (confidence: 0.92). Correct decision. Reward: +0.10
619
+
620
+
621
+ ## Step 8
622
+
623
+ ### Observation (sent to LLM)
624
+ ```
625
+ Queue: Task: Basic Ad Triage (easy)
626
+ Total ads: 5 | Reviewed: 4 | Pending: 1 | Budget remaining: 21 actions | Step: 8
627
+
628
+ Current Ad: === Ad in Focus: ad_005 ===
629
+ Category: fake_giveaway
630
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
631
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
632
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
633
+ Investigations completed: None yet
634
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
635
+
636
+ Feedback: Verdict recorded for ad_004: approve (confidence: 0.92). Correct decision. Reward: +0.10
637
+
638
+ Available ads: ad_005
639
+
640
+ Verdicts: ad_001: approve (confidence: 0.90)
641
+ ad_002: reject (confidence: 0.95)
642
+ ad_003: reject (confidence: 0.98)
643
+ ad_004: approve (confidence: 0.92)
644
+
645
+ Findings:
646
+ [ad_001 / landing_page]
647
+ Domain: nutri.com
648
+ Domain age: 1672 days
649
+ SSL: Valid SSL certificate
650
+ Registrar: Cloudflare Registrar
651
+ Redirect chain: No redirects (direct)
652
+ Similarity to known scam templates: 5%
653
+ Key claims on page:
654
+ - Licensed and insured
655
+ - Established since 2010
656
+ - BBB accredited
657
+ Suspicious elements:
658
+ None detected
659
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
660
+
661
+ [ad_002 / landing_page]
662
+ Domain: store4751.site
663
+ Domain age: 6 days
664
+ SSL: NO SSL / expired certificate
665
+ Registrar: NameSilo
666
+ Redire
667
+ ```
668
+
669
+ ### LLM Response
670
+ ```json
671
+ {
672
+ "action_type": "investigate",
673
+ "ad_id": "ad_005",
674
+ "investigation_target": "landing_page"
675
+ }
676
+ ```
677
+
678
+ ### Parsed Action
679
+ ```json
680
+ {
681
+ "action_type": "investigate",
682
+ "ad_id": "ad_005",
683
+ "investigation_target": "landing_page"
684
+ }
685
+ ```
686
+
687
+ ### Environment Response
688
+ - **Reward:** `+0.00`
689
+ - **Done:** `False`
690
+
691
+ - **Feedback:** Investigation complete: landing_page for ad_005.
692
+ --- Findings ---
693
+ Domain: pro3268.top
694
+ Domain age: 10 days
695
+ SSL: Valid SSL certificate
696
+ Registrar: Epik
697
+ Redirect chain: pro3268.top -> redirect614..top -> final-501..site
698
+ Similarity to known scam templates: 74%
699
+ Key claims on page:
700
+ - 100% free, no purchase necessary
701
+ - Guaranteed winner
702
+ - Act now — expires in 2 hours
703
+ Suspicious elements:
704
+ - No contact phone number
705
+ - Terms of service link returns 404
706
+ - Requests personal info before any value provided
707
+ Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
708
+
709
+
710
+ ## Step 9
711
+
712
+ ### Observation (sent to LLM)
713
+ ```
714
+ Queue: Task: Basic Ad Triage (easy)
715
+ Total ads: 5 | Reviewed: 4 | Pending: 1 | Budget remaining: 20 actions | Step: 9
716
+
717
+ Current Ad: === Ad in Focus: ad_005 ===
718
+ Category: fake_giveaway
719
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
720
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
721
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
722
+ Investigations completed: landing_page
723
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
724
+
725
+ Feedback: Investigation complete: landing_page for ad_005.
726
+ --- Findings ---
727
+ Domain: pro3268.top
728
+ Domain age: 10 days
729
+ SSL: Valid SSL certificate
730
+ Registrar: Epik
731
+ Redirect chain: pro3268.top -> redirect614..top -> final-501..site
732
+ Similarity to known scam templates: 74%
733
+ Key claims on page:
734
+ - 100% free, no purchase necessary
735
+ - Guaranteed winner
736
+ - Act now — expires in 2 hours
737
+ Suspicious elements:
738
+ - No contact phone number
739
+ - Terms of service link returns 404
740
+ - Requests personal info before any value provided
741
+ Content summary: Landing page features a large countdown timer and a form requesting name, e... [truncated]
742
+
743
+ Available ads: ad_005
744
+
745
+ Verdicts: ad_001: approve (confidence: 0.90)
746
+ ad_002: reject (confidence: 0.95)
747
+ ad_003: reject (confidence: 0.98)
748
+ ad_004: approve (confidence: 0.92)
749
+
750
+ Findings:
751
+ [ad_001 / landing_page]
752
+ Domain: nutri.com
753
+ Domain
754
+ ```
755
+
756
+ ### LLM Response
757
+ ```json
758
+ {
759
+ "action_type": "verdict",
760
+ "ad_id": "ad_005",
761
+ "verdict": "reject",
762
+ "confidence": 0.99
763
+ }
764
+ ```
765
+
766
+ ### Parsed Action
767
+ ```json
768
+ {
769
+ "action_type": "verdict",
770
+ "ad_id": "ad_005",
771
+ "verdict": "reject",
772
+ "confidence": 0.99
773
+ }
774
+ ```
775
+
776
+ ### Environment Response
777
+ - **Reward:** `+0.00`
778
+ - **Done:** `False`
779
+
780
+ - **Feedback:** Episode complete. Grader score: 0.931/1.000
781
+ Verdicts rendered: 5/5
782
+ Auto-approved (unreviewed): 0 fraud ad(s) missed
783
+ Correct decisions: 5/5
784
+
785
+
786
+ ## Step 10
787
+
788
+ ### Observation (sent to LLM)
789
+ ```
790
+ Queue: Task: Basic Ad Triage (easy)
791
+ Total ads: 5 | Reviewed: 5 | Pending: 0 | Budget remaining: 20 actions | Step: 10
792
+
793
+ Current Ad:
794
+
795
+ Feedback: Episode complete. Grader score: 0.931/1.000
796
+ Verdicts rendered: 5/5
797
+ Auto-approved (unreviewed): 0 fraud ad(s) missed
798
+ Correct decisions: 5/5
799
+
800
+ Available ads:
801
+
802
+ Verdicts: ad_001: approve (confidence: 0.90)
803
+ ad_002: reject (confidence: 0.95)
804
+ ad_003: reject (confidence: 0.98)
805
+ ad_004: approve (confidence: 0.92)
806
+ ad_005: reject (confidence: 0.99)
807
+
808
+ Findings:
809
+ [ad_001 / landing_page]
810
+ Domain: nutri.com
811
+ Domain age: 1672 days
812
+ SSL: Valid SSL certificate
813
+ Registrar: Cloudflare Registrar
814
+ Redirect chain: No redirects (direct)
815
+ Similarity to known scam templates: 5%
816
+ Key claims on page:
817
+ - Licensed and insured
818
+ - Established since 2010
819
+ - BBB accredited
820
+ Suspicious elements:
821
+ None detected
822
+ Content summary: E-commerce store with detailed product descriptions, customer reviews, FAQ section, and accessible support.
823
+
824
+ [ad_002 / landing_page]
825
+ Domain: store4751.site
826
+ Domain age: 6 days
827
+ SSL: NO SSL / expired certificate
828
+ Registrar: NameSilo
829
+ Redirect chain: store4751.... [truncated]
830
+ ```
831
+
832
+ ### LLM Response
833
+ ```json
834
+ {}
835
+ ```
836
+
837
+
838
+ ---
839
+ ## Result
840
+ **Score: 0.931 | Steps: 10 | Verdicts: 5/5**
outputs/task_2_conversation.md ADDED
@@ -0,0 +1,1251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Episode Log — task_2
2
+
3
+
4
+ ## Step 0
5
+
6
+ ### Observation (sent to LLM)
7
+ ```
8
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
9
+ Total ads: 12 | Reviewed: 0 | Pending: 12 | Budget remaining: 30 actions | Step: 0
10
+
11
+ Current Ad: === Ad in Focus: ad_001 ===
12
+ Category: local_service
13
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
14
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
15
+ Initial risk signals: None
16
+ Investigations completed: None yet
17
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
18
+
19
+ Feedback: Episode started. Review the ad queue and begin your investigation.
20
+
21
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
22
+ ```
23
+
24
+ ### LLM Response
25
+ ```json
26
+ {
27
+ "action_type": "investigate",
28
+ "ad_id": "ad_001",
29
+ "investigation_target": "advertiser_history"
30
+ }
31
+ ```
32
+
33
+ ### Parsed Action
34
+ ```json
35
+ {
36
+ "action_type": "investigate",
37
+ "ad_id": "ad_001",
38
+ "investigation_target": "advertiser_history"
39
+ }
40
+ ```
41
+
42
+ ### Environment Response
43
+ - **Reward:** `+0.00`
44
+ - **Done:** `False`
45
+
46
+ - **Feedback:** Investigation complete: advertiser_history for ad_001.
47
+ --- Findings ---
48
+ Advertiser: FitVisit (Verified Business)
49
+ Account age: 1558 days
50
+ Country: Canada
51
+ Total historical spend: $55,594.09
52
+ Ads submitted in last 30 days: 13
53
+ Historical approval rate: 91%
54
+ Payment method: credit_card (ID: pmt_865179)
55
+
56
+
57
+ ## Step 1
58
+
59
+ ### Observation (sent to LLM)
60
+ ```
61
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
62
+ Total ads: 12 | Reviewed: 0 | Pending: 12 | Budget remaining: 29 actions | Step: 1
63
+
64
+ Current Ad: === Ad in Focus: ad_001 ===
65
+ Category: local_service
66
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
67
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
68
+ Initial risk signals: None
69
+ Investigations completed: advertiser_history
70
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
71
+
72
+ Feedback: Investigation complete: advertiser_history for ad_001.
73
+ --- Findings ---
74
+ Advertiser: FitVisit (Verified Business)
75
+ Account age: 1558 days
76
+ Country: Canada
77
+ Total historical spend: $55,594.09
78
+ Ads submitted in last 30 days: 13
79
+ Historical approval rate: 91%
80
+ Payment method: credit_card (ID: pmt_865179)
81
+
82
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
83
+
84
+ Findings:
85
+ [ad_001 / advertiser_history]
86
+ Advertiser: FitVisit (Verified Business)
87
+ Account age: 1558 days
88
+ Country: Canada
89
+ Total historical spend: $55,594.09
90
+ Ads submitted in last 30 days: 13
91
+ Historical approval rate: 91%
92
+ Payment method: credit_card (ID: pmt_865179)
93
+ ```
94
+
95
+ ### LLM Response
96
+ ```json
97
+ {
98
+ "action_type": "investigate",
99
+ "ad_id": "ad_001",
100
+ "investigation_target": "payment_method"
101
+ }
102
+ ```
103
+
104
+ ### Parsed Action
105
+ ```json
106
+ {
107
+ "action_type": "investigate",
108
+ "ad_id": "ad_001",
109
+ "investigation_target": "payment_method"
110
+ }
111
+ ```
112
+
113
+ ### Environment Response
114
+ - **Reward:** `+0.00`
115
+ - **Done:** `False`
116
+
117
+ - **Feedback:** Investigation complete: payment_method for ad_001.
118
+ --- Findings ---
119
+ Payment Method Analysis for ad_001:
120
+ Method type: credit_card
121
+ Payment ID: pmt_865179
122
+ No other accounts in the current queue share this payment method.
123
+
124
+
125
+ ## Step 2
126
+
127
+ ### Observation (sent to LLM)
128
+ ```
129
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
130
+ Total ads: 12 | Reviewed: 0 | Pending: 12 | Budget remaining: 28 actions | Step: 2
131
+
132
+ Current Ad: === Ad in Focus: ad_001 ===
133
+ Category: local_service
134
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
135
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
136
+ Initial risk signals: None
137
+ Investigations completed: advertiser_history, payment_method
138
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
139
+
140
+ Feedback: Investigation complete: payment_method for ad_001.
141
+ --- Findings ---
142
+ Payment Method Analysis for ad_001:
143
+ Method type: credit_card
144
+ Payment ID: pmt_865179
145
+ No other accounts in the current queue share this payment method.
146
+
147
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
148
+
149
+ Findings:
150
+ [ad_001 / advertiser_history]
151
+ Advertiser: FitVisit (Verified Business)
152
+ Account age: 1558 days
153
+ Country: Canada
154
+ Total historical spend: $55,594.09
155
+ Ads submitted in last 30 days: 13
156
+ Historical approval rate: 91%
157
+ Payment method: credit_card (ID: pmt_865179)
158
+
159
+ [ad_001 / payment_method]
160
+ Payment Method Analysis for ad_001:
161
+ Method type: credit_card
162
+ Payment ID: pmt_865179
163
+ No other accounts in the current queue share this payment method.
164
+ ```
165
+
166
+ ### LLM Response
167
+ ```json
168
+ {
169
+ "action_type": "verdict",
170
+ "ad_id": "ad_001",
171
+ "verdict": "approve",
172
+ "confidence": 0.9
173
+ }
174
+ ```
175
+
176
+ ### Parsed Action
177
+ ```json
178
+ {
179
+ "action_type": "verdict",
180
+ "ad_id": "ad_001",
181
+ "verdict": "approve",
182
+ "confidence": 0.9
183
+ }
184
+ ```
185
+
186
+ ### Environment Response
187
+ - **Reward:** `+0.00`
188
+ - **Done:** `False`
189
+
190
+ - **Feedback:** Verdict recorded for ad_001: approve (confidence: 0.90). Correct decision. Reward: +0.10
191
+
192
+
193
+ ## Step 3
194
+
195
+ ### Observation (sent to LLM)
196
+ ```
197
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
198
+ Total ads: 12 | Reviewed: 1 | Pending: 11 | Budget remaining: 28 actions | Step: 3
199
+
200
+ Current Ad: === Ad in Focus: ad_002 ===
201
+ Category: fake_giveaway
202
+ Ad copy: "CONGRATULATIONS!! You've been selected to win a FREE iPhone 16 Pro!! Click NOW before offer expires!!!"
203
+ Targeting: All adults 18+, broad targeting, worldwide
204
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
205
+ Investigations completed: None yet
206
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
207
+
208
+ Feedback: Verdict recorded for ad_001: approve (confidence: 0.90). Correct decision. Reward: +0.10
209
+
210
+ Available ads: ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
211
+
212
+ Verdicts: ad_001: approve (confidence: 0.90)
213
+
214
+ Findings:
215
+ [ad_001 / advertiser_history]
216
+ Advertiser: FitVisit (Verified Business)
217
+ Account age: 1558 days
218
+ Country: Canada
219
+ Total historical spend: $55,594.09
220
+ Ads submitted in last 30 days: 13
221
+ Historical approval rate: 91%
222
+ Payment method: credit_card (ID: pmt_865179)
223
+
224
+ [ad_001 / payment_method]
225
+ Payment Method Analysis for ad_001:
226
+ Method type: credit_card
227
+ Payment ID: pmt_865179
228
+ No other accounts in the current queue share this payment method.
229
+ ```
230
+
231
+ ### LLM Response
232
+ ```json
233
+ {
234
+ "action_type": "investigate",
235
+ "ad_id": "ad_002",
236
+ "investigation_target": "landing_page"
237
+ }
238
+ ```
239
+
240
+ ### Parsed Action
241
+ ```json
242
+ {
243
+ "action_type": "investigate",
244
+ "ad_id": "ad_002",
245
+ "investigation_target": "landing_page"
246
+ }
247
+ ```
248
+
249
+ ### Environment Response
250
+ - **Reward:** `+0.00`
251
+ - **Done:** `False`
252
+
253
+ - **Feedback:** Investigation complete: landing_page for ad_002.
254
+ --- Findings ---
255
+ Domain: shop4020.top
256
+ Domain age: 11 days
257
+ SSL: NO SSL / expired certificate
258
+ Registrar: Njalla (privacy)
259
+ Redirect chain: shop4020.top -> redirect702..top -> redirect864..click -> final-235..site
260
+ Similarity to known scam templates: 95%
261
+ Key claims on page:
262
+ - 100% free, no purchase necessary
263
+ - Guaranteed winner
264
+ - Act now — expires in 2 hours
265
+ Suspicious elements:
266
+ - No contact phone number
267
+ - Countdown timer with fake urgency
268
+ - No physical address listed
269
+ - Requests personal info before any value provided
270
+ Content summary: Landing page features a large countdown timer and a form requesting name, email, phone, and address. No clear sponsor or rules disclosure.
271
+
272
+
273
+ ## Step 4
274
+
275
+ ### Observation (sent to LLM)
276
+ ```
277
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
278
+ Total ads: 12 | Reviewed: 1 | Pending: 11 | Budget remaining: 27 actions | Step: 4
279
+
280
+ Current Ad: === Ad in Focus: ad_002 ===
281
+ Category: fake_giveaway
282
+ Ad copy: "CONGRATULATIONS!! You've been selected to win a FREE iPhone 16 Pro!! Click NOW before offer expires!!!"
283
+ Targeting: All adults 18+, broad targeting, worldwide
284
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
285
+ Investigations completed: landing_page
286
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
287
+
288
+ Feedback: Investigation complete: landing_page for ad_002.
289
+ --- Findings ---
290
+ Domain: shop4020.top
291
+ Domain age: 11 days
292
+ SSL: NO SSL / expired certificate
293
+ Registrar: Njalla (privacy)
294
+ Redirect chain: shop4020.top -> redirect702..top -> redirect864..click -> final-235..site
295
+ Similarity to known scam templates: 95%
296
+ Key claims on page:
297
+ - 100% free, no purchase necessary
298
+ - Guaranteed winner
299
+ - Act now — expires in 2 hours
300
+ Suspicious elements:
301
+ - No contact phone number
302
+ - Countdown timer with fake urgency
303
+ - No physical address listed
304
+ - Requests personal info before any value provided
305
+ Content summary: L... [truncated]
306
+
307
+ Available ads: ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
308
+
309
+ Verdicts: ad_001: approve (confidence: 0.90)
310
+
311
+ Findings:
312
+ [ad_001 / advertiser_history]
313
+ Advert
314
+ ```
315
+
316
+ ### LLM Response
317
+ ```json
318
+ {
319
+ "action_type": "verdict",
320
+ "ad_id": "ad_002",
321
+ "verdict": "reject",
322
+ "confidence": 0.95
323
+ }
324
+ ```
325
+
326
+ ### Parsed Action
327
+ ```json
328
+ {
329
+ "action_type": "verdict",
330
+ "ad_id": "ad_002",
331
+ "verdict": "reject",
332
+ "confidence": 0.95
333
+ }
334
+ ```
335
+
336
+ ### Environment Response
337
+ - **Reward:** `+0.00`
338
+ - **Done:** `False`
339
+
340
+ - **Feedback:** Verdict recorded for ad_002: reject (confidence: 0.95). Correct decision. Reward: +0.39
341
+
342
+
343
+ ## Step 5
344
+
345
+ ### Observation (sent to LLM)
346
+ ```
347
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
348
+ Total ads: 12 | Reviewed: 2 | Pending: 10 | Budget remaining: 27 actions | Step: 5
349
+
350
+ Current Ad: === Ad in Focus: ad_003 ===
351
+ Category: fake_crypto
352
+ Ad copy: "Web3 real estate tokenization. Own fractions of premium properties. Starting at $100. Licensed broker-dealer. PropChain.io"
353
+ Targeting: Adults 30-55, interests: real estate, investing, crypto
354
+ Initial risk signals: high guaranteed returns, complex financial product
355
+ Investigations completed: None yet
356
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
357
+
358
+ Feedback: Verdict recorded for ad_002: reject (confidence: 0.95). Correct decision. Reward: +0.39
359
+
360
+ Available ads: ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
361
+
362
+ Verdicts: ad_001: approve (confidence: 0.90)
363
+ ad_002: reject (confidence: 0.95)
364
+
365
+ Findings:
366
+ [ad_001 / advertiser_history]
367
+ Advertiser: FitVisit (Verified Business)
368
+ Account age: 1558 days
369
+ Country: Canada
370
+ Total historical spend: $55,594.09
371
+ Ads submitted in last 30 days: 13
372
+ Historical approval rate: 91%
373
+ Payment method: credit_card (ID: pmt_865179)
374
+
375
+ [ad_001 / payment_method]
376
+ Payment Method Analysis for ad_001:
377
+ Method type: credit_card
378
+ Payment ID: pmt_865179
379
+ No other accounts in the current queue share this payment method.
380
+
381
+ [ad_002 / landing_page]
382
+ Domain: shop4020.top
383
+ Domain age: 11 days
384
+ SSL: NO SSL / expired certificate
385
+ Registrar: Njalla (privacy)
386
+ Redir
387
+ ```
388
+
389
+ ### LLM Response
390
+ ```json
391
+ {
392
+ "action_type": "investigate",
393
+ "ad_id": "ad_003",
394
+ "investigation_target": "landing_page"
395
+ }
396
+ ```
397
+
398
+ ### Parsed Action
399
+ ```json
400
+ {
401
+ "action_type": "investigate",
402
+ "ad_id": "ad_003",
403
+ "investigation_target": "landing_page"
404
+ }
405
+ ```
406
+
407
+ ### Environment Response
408
+ - **Reward:** `+0.00`
409
+ - **Done:** `False`
410
+
411
+ - **Feedback:** Investigation complete: landing_page for ad_003.
412
+ --- Findings ---
413
+ Domain: best3603.xyz
414
+ Domain age: 9 days
415
+ SSL: NO SSL / expired certificate
416
+ Registrar: NameSilo
417
+ Redirect chain: best3603.xyz -> redirect157..xyz -> final-164..store
418
+ Similarity to known scam templates: 84%
419
+ Key claims on page:
420
+ - 12-15% monthly returns guaranteed
421
+ - Audited by top security firms
422
+ - Risk-free investment
423
+ Suspicious elements:
424
+ - Team photos are stock images
425
+ - Privacy policy copied from template
426
+ - No contact phone number
427
+ - Terms of service link returns 404
428
+ Content summary: Professional-looking platform with dashboard screenshots. Whitepaper link leads to a generic PDF. Team bios use stock photos.
429
+
430
+
431
+ ## Step 6
432
+
433
+ ### Observation (sent to LLM)
434
+ ```
435
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
436
+ Total ads: 12 | Reviewed: 2 | Pending: 10 | Budget remaining: 26 actions | Step: 6
437
+
438
+ Current Ad: === Ad in Focus: ad_003 ===
439
+ Category: fake_crypto
440
+ Ad copy: "Web3 real estate tokenization. Own fractions of premium properties. Starting at $100. Licensed broker-dealer. PropChain.io"
441
+ Targeting: Adults 30-55, interests: real estate, investing, crypto
442
+ Initial risk signals: high guaranteed returns, complex financial product
443
+ Investigations completed: landing_page
444
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
445
+
446
+ Feedback: Investigation complete: landing_page for ad_003.
447
+ --- Findings ---
448
+ Domain: best3603.xyz
449
+ Domain age: 9 days
450
+ SSL: NO SSL / expired certificate
451
+ Registrar: NameSilo
452
+ Redirect chain: best3603.xyz -> redirect157..xyz -> final-164..store
453
+ Similarity to known scam templates: 84%
454
+ Key claims on page:
455
+ - 12-15% monthly returns guaranteed
456
+ - Audited by top security firms
457
+ - Risk-free investment
458
+ Suspicious elements:
459
+ - Team photos are stock images
460
+ - Privacy policy copied from template
461
+ - No contact phone number
462
+ - Terms of service link returns 404
463
+ Content summary: Professional-looking platform with da... [truncated]
464
+
465
+ Available ads: ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
466
+
467
+ Verdicts: ad_001: approve (confidence: 0.90)
468
+ ad_002: reject (confidence: 0.95)
469
+
470
+ Findings:
471
+ [ad_001 / advertiser_history]
472
+ Advertis
473
+ ```
474
+
475
+ ### LLM Response
476
+ ```json
477
+ {
478
+ "action_type": "investigate",
479
+ "ad_id": "ad_003",
480
+ "investigation_target": "payment_method"
481
+ }
482
+ ```
483
+
484
+ ### Parsed Action
485
+ ```json
486
+ {
487
+ "action_type": "investigate",
488
+ "ad_id": "ad_003",
489
+ "investigation_target": "payment_method"
490
+ }
491
+ ```
492
+
493
+ ### Environment Response
494
+ - **Reward:** `+0.00`
495
+ - **Done:** `False`
496
+
497
+ - **Feedback:** Investigation complete: payment_method for ad_003.
498
+ --- Findings ---
499
+ Payment Method Analysis for ad_003:
500
+ Method type: wire_transfer
501
+ Payment ID: pmt_711878
502
+ No other accounts in the current queue share this payment method.
503
+ Payment history: 1 chargebacks/disputes on record.
504
+
505
+
506
+ ## Step 7
507
+
508
+ ### Observation (sent to LLM)
509
+ ```
510
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
511
+ Total ads: 12 | Reviewed: 2 | Pending: 10 | Budget remaining: 25 actions | Step: 7
512
+
513
+ Current Ad: === Ad in Focus: ad_003 ===
514
+ Category: fake_crypto
515
+ Ad copy: "Web3 real estate tokenization. Own fractions of premium properties. Starting at $100. Licensed broker-dealer. PropChain.io"
516
+ Targeting: Adults 30-55, interests: real estate, investing, crypto
517
+ Initial risk signals: high guaranteed returns, complex financial product
518
+ Investigations completed: landing_page, payment_method
519
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
520
+
521
+ Feedback: Investigation complete: payment_method for ad_003.
522
+ --- Findings ---
523
+ Payment Method Analysis for ad_003:
524
+ Method type: wire_transfer
525
+ Payment ID: pmt_711878
526
+ No other accounts in the current queue share this payment method.
527
+ Payment history: 1 chargebacks/disputes on record.
528
+
529
+ Available ads: ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
530
+
531
+ Verdicts: ad_001: approve (confidence: 0.90)
532
+ ad_002: reject (confidence: 0.95)
533
+
534
+ Findings:
535
+ [ad_001 / advertiser_history]
536
+ Advertiser: FitVisit (Verified Business)
537
+ Account age: 1558 days
538
+ Country: Canada
539
+ Total historical spend: $55,594.09
540
+ Ads submitted in last 30 days: 13
541
+ Historical approval rate: 91%
542
+ Payment method: credit_card (ID: pmt_865179)
543
+
544
+ [ad_001 / payment_method]
545
+ Payment Method Analysis for ad_001:
546
+ Method type: credit_card
547
+ Payment ID: p
548
+ ```
549
+
550
+ ### LLM Response
551
+ ```json
552
+ {}
553
+ ```
554
+
555
+ ### Parsed Action [FALLBACK]
556
+ ```json
557
+ {
558
+ "action_type": "verdict",
559
+ "ad_id": "ad_003",
560
+ "verdict": "escalate",
561
+ "confidence": 0.3
562
+ }
563
+ ```
564
+
565
+ ### Environment Response
566
+ - **Reward:** `+0.00`
567
+ - **Done:** `False`
568
+
569
+ - **Feedback:** Verdict recorded for ad_003: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
570
+
571
+
572
+ ## Step 8
573
+
574
+ ### Observation (sent to LLM)
575
+ ```
576
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
577
+ Total ads: 12 | Reviewed: 3 | Pending: 9 | Budget remaining: 25 actions | Step: 8
578
+
579
+ Current Ad: === Ad in Focus: ad_004 ===
580
+ Category: gray_area_supplements
581
+ Ad copy: "SleepDeep Ultra — melatonin-free natural sleep aid. Non-habit forming. Money-back guarantee. PharmGrade Labs."
582
+ Targeting: Adults 30-65, interests: sleep, insomnia, wellness
583
+ Initial risk signals: supplement with strong efficacy claims, medical-sounding language
584
+ Investigations completed: None yet
585
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
586
+
587
+ Feedback: Verdict recorded for ad_003: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
588
+
589
+ Available ads: ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
590
+
591
+ Verdicts: ad_001: approve (confidence: 0.90)
592
+ ad_002: reject (confidence: 0.95)
593
+ ad_003: escalate (confidence: 0.30)
594
+
595
+ Findings:
596
+ [ad_001 / advertiser_history]
597
+ Advertiser: FitVisit (Verified Business)
598
+ Account age: 1558 days
599
+ Country: Canada
600
+ Total historical spend: $55,594.09
601
+ Ads submitted in last 30 days: 13
602
+ Historical approval rate: 91%
603
+ Payment method: credit_card (ID: pmt_865179)
604
+
605
+ [ad_001 / payment_method]
606
+ Payment Method Analysis for ad_001:
607
+ Method type: credit_card
608
+ Payment ID: pmt_865179
609
+ No other accounts in the current queue share this payment method.
610
+
611
+ [ad_002 / landing_page]
612
+ Domain: shop4020.top
613
+ Domain age: 11 days
614
+ SSL: NO SSL / expired certi
615
+ ```
616
+
617
+ ### LLM Response
618
+ ```json
619
+ {}
620
+ ```
621
+
622
+ ### Parsed Action [FALLBACK]
623
+ ```json
624
+ {
625
+ "action_type": "verdict",
626
+ "ad_id": "ad_004",
627
+ "verdict": "escalate",
628
+ "confidence": 0.3
629
+ }
630
+ ```
631
+
632
+ ### Environment Response
633
+ - **Reward:** `+0.00`
634
+ - **Done:** `False`
635
+
636
+ - **Feedback:** Verdict recorded for ad_004: escalate (confidence: 0.30). Correct decision. Reward: +0.15
637
+
638
+
639
+ ## Step 9
640
+
641
+ ### Observation (sent to LLM)
642
+ ```
643
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
644
+ Total ads: 12 | Reviewed: 4 | Pending: 8 | Budget remaining: 25 actions | Step: 9
645
+
646
+ Current Ad: === Ad in Focus: ad_005 ===
647
+ Category: gray_area_supplements
648
+ Ad copy: "TestoMax Elite — natural testosterone support for men over 40. Doctor-formulated. Published clinical trial results."
649
+ Targeting: Men 40-65, interests: men's health, testosterone, fitness
650
+ Initial risk signals: supplement with strong efficacy claims, medical-sounding language
651
+ Investigations completed: None yet
652
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
653
+
654
+ Feedback: Verdict recorded for ad_004: escalate (confidence: 0.30). Correct decision. Reward: +0.15
655
+
656
+ Available ads: ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
657
+
658
+ Verdicts: ad_001: approve (confidence: 0.90)
659
+ ad_002: reject (confidence: 0.95)
660
+ ad_003: escalate (confidence: 0.30)
661
+ ad_004: escalate (confidence: 0.30)
662
+
663
+ Findings:
664
+ [ad_001 / advertiser_history]
665
+ Advertiser: FitVisit (Verified Business)
666
+ Account age: 1558 days
667
+ Country: Canada
668
+ Total historical spend: $55,594.09
669
+ Ads submitted in last 30 days: 13
670
+ Historical approval rate: 91%
671
+ Payment method: credit_card (ID: pmt_865179)
672
+
673
+ [ad_001 / payment_method]
674
+ Payment Method Analysis for ad_001:
675
+ Method type: credit_card
676
+ Payment ID: pmt_865179
677
+ No other accounts in the current queue share this payment method.
678
+
679
+ [ad_002 / landing_page]
680
+ Domain: shop4020.top
681
+ Domain
682
+ ```
683
+
684
+ ### LLM Response
685
+ ```json
686
+ {}
687
+ ```
688
+
689
+ ### Parsed Action [FALLBACK]
690
+ ```json
691
+ {
692
+ "action_type": "verdict",
693
+ "ad_id": "ad_005",
694
+ "verdict": "escalate",
695
+ "confidence": 0.3
696
+ }
697
+ ```
698
+
699
+ ### Environment Response
700
+ - **Reward:** `+0.00`
701
+ - **Done:** `False`
702
+
703
+ - **Feedback:** Verdict recorded for ad_005: escalate (confidence: 0.30). Correct decision. Reward: +0.15
704
+
705
+
706
+ ## Step 10
707
+
708
+ ### Observation (sent to LLM)
709
+ ```
710
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
711
+ Total ads: 12 | Reviewed: 5 | Pending: 7 | Budget remaining: 25 actions | Step: 10
712
+
713
+ Current Ad: === Ad in Focus: ad_006 ===
714
+ Category: ecommerce
715
+ Ad copy: "Handcrafted leather bags — designed to last. Each piece unique. ArtisanHide.com"
716
+ Targeting: Adults 25-55, interests: fashion, luxury goods
717
+ Initial risk signals: None
718
+ Investigations completed: None yet
719
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
720
+
721
+ Feedback: Verdict recorded for ad_005: escalate (confidence: 0.30). Correct decision. Reward: +0.15
722
+
723
+ Available ads: ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
724
+
725
+ Verdicts: ad_001: approve (confidence: 0.90)
726
+ ad_002: reject (confidence: 0.95)
727
+ ad_003: escalate (confidence: 0.30)
728
+ ad_004: escalate (confidence: 0.30)
729
+ ad_005: escalate (confidence: 0.30)
730
+
731
+ Findings:
732
+ [ad_001 / advertiser_history]
733
+ Advertiser: FitVisit (Verified Business)
734
+ Account age: 1558 days
735
+ Country: Canada
736
+ Total historical spend: $55,594.09
737
+ Ads submitted in last 30 days: 13
738
+ Historical approval rate: 91%
739
+ Payment method: credit_card (ID: pmt_865179)
740
+
741
+ [ad_001 / payment_method]
742
+ Payment Method Analysis for ad_001:
743
+ Method type: credit_card
744
+ Payment ID: pmt_865179
745
+ No other accounts in the current queue share this payment method.
746
+
747
+ [ad_002 / landing_page]
748
+ Domain: shop4020.top
749
+ Domain age: 11 days
750
+ SSL: NO SSL / expired certificate
751
+ Registrar: Njalla (privacy)
752
+ Redirect chai
753
+ ```
754
+
755
+ ### LLM Response
756
+ ```json
757
+ {}
758
+ ```
759
+
760
+ ### Parsed Action [FALLBACK]
761
+ ```json
762
+ {
763
+ "action_type": "verdict",
764
+ "ad_id": "ad_006",
765
+ "verdict": "escalate",
766
+ "confidence": 0.3
767
+ }
768
+ ```
769
+
770
+ ### Environment Response
771
+ - **Reward:** `+0.00`
772
+ - **Done:** `False`
773
+
774
+ - **Feedback:** Verdict recorded for ad_006: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
775
+
776
+
777
+ ## Step 11
778
+
779
+ ### Observation (sent to LLM)
780
+ ```
781
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
782
+ Total ads: 12 | Reviewed: 6 | Pending: 6 | Budget remaining: 25 actions | Step: 11
783
+
784
+ Current Ad: === Ad in Focus: ad_007 ===
785
+ Category: advance_fee
786
+ Ad copy: "URGENT: I am Prince Adebayo of Nigeria. I need your help to transfer $4.5 million. You keep 30%."
787
+ Targeting: All adults 18+, broad targeting
788
+ Initial risk signals: advance fee request, unsolicited money offer, urgency pressure, request for personal information
789
+ Investigations completed: None yet
790
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
791
+
792
+ Feedback: Verdict recorded for ad_006: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
793
+
794
+ Available ads: ad_007, ad_008, ad_009, ad_010, ad_011, ad_012
795
+
796
+ Verdicts: ad_001: approve (confidence: 0.90)
797
+ ad_002: reject (confidence: 0.95)
798
+ ad_003: escalate (confidence: 0.30)
799
+ ad_004: escalate (confidence: 0.30)
800
+ ad_005: escalate (confidence: 0.30)
801
+ ad_006: escalate (confidence: 0.30)
802
+
803
+ Findings:
804
+ [ad_001 / advertiser_history]
805
+ Advertiser: FitVisit (Verified Business)
806
+ Account age: 1558 days
807
+ Country: Canada
808
+ Total historical spend: $55,594.09
809
+ Ads submitted in last 30 days: 13
810
+ Historical approval rate: 91%
811
+ Payment method: credit_card (ID: pmt_865179)
812
+
813
+ [ad_001 / payment_method]
814
+ Payment Method Analysis for ad_001:
815
+ Method type: credit_card
816
+ Payment ID: pmt_865179
817
+ No other accounts in the current queue share this payment method.
818
+
819
+ [ad_002 / la
820
+ ```
821
+
822
+ ### LLM Response
823
+ ```json
824
+ {}
825
+ ```
826
+
827
+ ### Parsed Action [FALLBACK]
828
+ ```json
829
+ {
830
+ "action_type": "verdict",
831
+ "ad_id": "ad_007",
832
+ "verdict": "escalate",
833
+ "confidence": 0.3
834
+ }
835
+ ```
836
+
837
+ ### Environment Response
838
+ - **Reward:** `+0.00`
839
+ - **Done:** `False`
840
+
841
+ - **Feedback:** Verdict recorded for ad_007: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
842
+
843
+
844
+ ## Step 12
845
+
846
+ ### Observation (sent to LLM)
847
+ ```
848
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
849
+ Total ads: 12 | Reviewed: 7 | Pending: 5 | Budget remaining: 25 actions | Step: 12
850
+
851
+ Current Ad: === Ad in Focus: ad_008 ===
852
+ Category: saas
853
+ Ad copy: "Automate your invoicing in minutes. Trusted by 50,000+ small businesses. InvoiceSimple."
854
+ Targeting: Small business owners, interests: finance, accounting
855
+ Initial risk signals: None
856
+ Investigations completed: None yet
857
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
858
+
859
+ Feedback: Verdict recorded for ad_007: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
860
+
861
+ Available ads: ad_008, ad_009, ad_010, ad_011, ad_012
862
+
863
+ Verdicts: ad_001: approve (confidence: 0.90)
864
+ ad_002: reject (confidence: 0.95)
865
+ ad_003: escalate (confidence: 0.30)
866
+ ad_004: escalate (confidence: 0.30)
867
+ ad_005: escalate (confidence: 0.30)
868
+ ad_006: escalate (confidence: 0.30)
869
+ ad_007: escalate (confidence: 0.30)
870
+
871
+ Findings:
872
+ [ad_001 / advertiser_history]
873
+ Advertiser: FitVisit (Verified Business)
874
+ Account age: 1558 days
875
+ Country: Canada
876
+ Total historical spend: $55,594.09
877
+ Ads submitted in last 30 days: 13
878
+ Historical approval rate: 91%
879
+ Payment method: credit_card (ID: pmt_865179)
880
+
881
+ [ad_001 / payment_method]
882
+ Payment Method Analysis for ad_001:
883
+ Method type: credit_card
884
+ Payment ID: pmt_865179
885
+ No other accounts in the current queue share this payment method.
886
+
887
+ [ad_002 / landing_page]
888
+ Domain: shop4020.top
889
+ Domain age: 11 days
890
+ SSL
891
+ ```
892
+
893
+ ### LLM Response
894
+ ```json
895
+ {}
896
+ ```
897
+
898
+ ### Parsed Action [FALLBACK]
899
+ ```json
900
+ {
901
+ "action_type": "verdict",
902
+ "ad_id": "ad_008",
903
+ "verdict": "escalate",
904
+ "confidence": 0.3
905
+ }
906
+ ```
907
+
908
+ ### Environment Response
909
+ - **Reward:** `+0.00`
910
+ - **Done:** `False`
911
+
912
+ - **Feedback:** Verdict recorded for ad_008: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
913
+
914
+
915
+ ## Step 13
916
+
917
+ ### Observation (sent to LLM)
918
+ ```
919
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
920
+ Total ads: 12 | Reviewed: 8 | Pending: 4 | Budget remaining: 25 actions | Step: 13
921
+
922
+ Current Ad: === Ad in Focus: ad_009 ===
923
+ Category: ecommerce
924
+ Ad copy: "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest."
925
+ Targeting: Women 25-54, interests: home decor, shopping
926
+ Initial risk signals: None
927
+ Investigations completed: None yet
928
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
929
+
930
+ Feedback: Verdict recorded for ad_008: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
931
+
932
+ Available ads: ad_009, ad_010, ad_011, ad_012
933
+
934
+ Verdicts: ad_001: approve (confidence: 0.90)
935
+ ad_002: reject (confidence: 0.95)
936
+ ad_003: escalate (confidence: 0.30)
937
+ ad_004: escalate (confidence: 0.30)
938
+ ad_005: escalate (confidence: 0.30)
939
+ ad_006: escalate (confidence: 0.30)
940
+ ad_007: escalate (confidence: 0.30)
941
+ ad_008: escalate (confidence: 0.30)
942
+
943
+ Findings:
944
+ [ad_001 / advertiser_history]
945
+ Advertiser: FitVisit (Verified Business)
946
+ Account age: 1558 days
947
+ Country: Canada
948
+ Total historical spend: $55,594.09
949
+ Ads submitted in last 30 days: 13
950
+ Historical approval rate: 91%
951
+ Payment method: credit_card (ID: pmt_865179)
952
+
953
+ [ad_001 / payment_method]
954
+ Payment Method Analysis for ad_001:
955
+ Method type: credit_card
956
+ Payment ID: pmt_865179
957
+ No other accounts in the current queue share this payment method.
958
+
959
+ [ad_002 / landing_page]
960
+ Domai
961
+ ```
962
+
963
+ ### LLM Response
964
+ ```json
965
+ {}
966
+ ```
967
+
968
+ ### Parsed Action [FALLBACK]
969
+ ```json
970
+ {
971
+ "action_type": "verdict",
972
+ "ad_id": "ad_009",
973
+ "verdict": "escalate",
974
+ "confidence": 0.3
975
+ }
976
+ ```
977
+
978
+ ### Environment Response
979
+ - **Reward:** `+0.00`
980
+ - **Done:** `False`
981
+
982
+ - **Feedback:** Verdict recorded for ad_009: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
983
+
984
+
985
+ ## Step 14
986
+
987
+ ### Observation (sent to LLM)
988
+ ```
989
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
990
+ Total ads: 12 | Reviewed: 9 | Pending: 3 | Budget remaining: 25 actions | Step: 14
991
+
992
+ Current Ad: === Ad in Focus: ad_010 ===
993
+ Category: ecommerce
994
+ Ad copy: "Handcrafted leather bags — designed to last. Each piece unique. ArtisanHide.com"
995
+ Targeting: Adults 25-55, interests: fashion, luxury goods
996
+ Initial risk signals: None
997
+ Investigations completed: None yet
998
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
999
+
1000
+ Feedback: Verdict recorded for ad_009: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
1001
+
1002
+ Available ads: ad_010, ad_011, ad_012
1003
+
1004
+ Verdicts: ad_001: approve (confidence: 0.90)
1005
+ ad_002: reject (confidence: 0.95)
1006
+ ad_003: escalate (confidence: 0.30)
1007
+ ad_004: escalate (confidence: 0.30)
1008
+ ad_005: escalate (confidence: 0.30)
1009
+ ad_006: escalate (confidence: 0.30)
1010
+ ad_007: escalate (confidence: 0.30)
1011
+ ad_008: escalate (confidence: 0.30)
1012
+ ad_009: escalate (confidence: 0.30)
1013
+
1014
+ Findings:
1015
+ [ad_001 / advertiser_history]
1016
+ Advertiser: FitVisit (Verified Business)
1017
+ Account age: 1558 days
1018
+ Country: Canada
1019
+ Total historical spend: $55,594.09
1020
+ Ads submitted in last 30 days: 13
1021
+ Historical approval rate: 91%
1022
+ Payment method: credit_card (ID: pmt_865179)
1023
+
1024
+ [ad_001 / payment_method]
1025
+ Payment Method Analysis for ad_001:
1026
+ Method type: credit_card
1027
+ Payment ID: pmt_865179
1028
+ No other accounts in the current queue share this payment method.
1029
+
1030
+ [ad_002 / landing_
1031
+ ```
1032
+
1033
+ ### LLM Response
1034
+ ```json
1035
+ {}
1036
+ ```
1037
+
1038
+ ### Parsed Action [FALLBACK]
1039
+ ```json
1040
+ {
1041
+ "action_type": "verdict",
1042
+ "ad_id": "ad_010",
1043
+ "verdict": "escalate",
1044
+ "confidence": 0.3
1045
+ }
1046
+ ```
1047
+
1048
+ ### Environment Response
1049
+ - **Reward:** `+0.00`
1050
+ - **Done:** `False`
1051
+
1052
+ - **Feedback:** Verdict recorded for ad_010: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
1053
+
1054
+
1055
+ ## Step 15
1056
+
1057
+ ### Observation (sent to LLM)
1058
+ ```
1059
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
1060
+ Total ads: 12 | Reviewed: 10 | Pending: 2 | Budget remaining: 25 actions | Step: 15
1061
+
1062
+ Current Ad: === Ad in Focus: ad_011 ===
1063
+ Category: fake_giveaway
1064
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
1065
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
1066
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
1067
+ Investigations completed: None yet
1068
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
1069
+
1070
+ Feedback: Verdict recorded for ad_010: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
1071
+
1072
+ Available ads: ad_011, ad_012
1073
+
1074
+ Verdicts: ad_001: approve (confidence: 0.90)
1075
+ ad_002: reject (confidence: 0.95)
1076
+ ad_003: escalate (confidence: 0.30)
1077
+ ad_004: escalate (confidence: 0.30)
1078
+ ad_005: escalate (confidence: 0.30)
1079
+ ad_006: escalate (confidence: 0.30)
1080
+ ad_007: escalate (confidence: 0.30)
1081
+ ad_008: escalate (confidence: 0.30)
1082
+ ad_009: escalate (confidence: 0.30)
1083
+ ad_010: escalate (confidence: 0.30)
1084
+
1085
+ Findings:
1086
+ [ad_001 / advertiser_history]
1087
+ Advertiser: FitVisit (Verified Business)
1088
+ Account age: 1558 days
1089
+ Country: Canada
1090
+ Total historical spend: $55,594.09
1091
+ Ads submitted in last 30 days: 13
1092
+ Historical approval rate: 91%
1093
+ Payment method: credit_card (ID: pmt_865179)
1094
+
1095
+ [ad_001 / payment_method]
1096
+ Payment Method Analysis fo
1097
+ ```
1098
+
1099
+ ### LLM Response
1100
+ ```json
1101
+ {}
1102
+ ```
1103
+
1104
+ ### Parsed Action [FALLBACK]
1105
+ ```json
1106
+ {
1107
+ "action_type": "verdict",
1108
+ "ad_id": "ad_011",
1109
+ "verdict": "escalate",
1110
+ "confidence": 0.3
1111
+ }
1112
+ ```
1113
+
1114
+ ### Environment Response
1115
+ - **Reward:** `+0.00`
1116
+ - **Done:** `False`
1117
+
1118
+ - **Feedback:** Verdict recorded for ad_011: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
1119
+
1120
+
1121
+ ## Step 16
1122
+
1123
+ ### Observation (sent to LLM)
1124
+ ```
1125
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
1126
+ Total ads: 12 | Reviewed: 11 | Pending: 1 | Budget remaining: 25 actions | Step: 16
1127
+
1128
+ Current Ad: === Ad in Focus: ad_012 ===
1129
+ Category: counterfeit_goods
1130
+ Ad copy: "Rolex watches starting at $49.99! 100% genuine! Free worldwide shipping! LuxWatchDeals.net"
1131
+ Targeting: Men 25-55, interests: watches, luxury
1132
+ Initial risk signals: impossibly low prices for luxury goods, unfamiliar domain, claims of authenticity with extreme discounts
1133
+ Investigations completed: None yet
1134
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
1135
+
1136
+ Feedback: Verdict recorded for ad_011: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
1137
+
1138
+ Available ads: ad_012
1139
+
1140
+ Verdicts: ad_001: approve (confidence: 0.90)
1141
+ ad_002: reject (confidence: 0.95)
1142
+ ad_003: escalate (confidence: 0.30)
1143
+ ad_004: escalate (confidence: 0.30)
1144
+ ad_005: escalate (confidence: 0.30)
1145
+ ad_006: escalate (confidence: 0.30)
1146
+ ad_007: escalate (confidence: 0.30)
1147
+ ad_008: escalate (confidence: 0.30)
1148
+ ad_009: escalate (confidence: 0.30)
1149
+ ad_010: escalate (confidence: 0.30)
1150
+ ad_011: escalate (con... [truncated]
1151
+
1152
+ Findings:
1153
+ [ad_001 / advertiser_history]
1154
+ Advertiser: FitVisit (Verified Business)
1155
+ Account age: 1558 days
1156
+ Country: Canada
1157
+ Total historical spend: $55,594.09
1158
+ Ads submitted in last 30 days: 13
1159
+ Historical approval rate: 91%
1160
+ Payment method: credit_card (ID: pmt_865179)
1161
+
1162
+ [ad_001 / payment_method]
1163
+ Pay
1164
+ ```
1165
+
1166
+ ### LLM Response
1167
+ ```json
1168
+ {}
1169
+ ```
1170
+
1171
+ ### Parsed Action [FALLBACK]
1172
+ ```json
1173
+ {
1174
+ "action_type": "verdict",
1175
+ "ad_id": "ad_012",
1176
+ "verdict": "escalate",
1177
+ "confidence": 0.3
1178
+ }
1179
+ ```
1180
+
1181
+ ### Environment Response
1182
+ - **Reward:** `+0.00`
1183
+ - **Done:** `False`
1184
+
1185
+ - **Feedback:** Episode complete. Grader score: 0.684/1.000
1186
+ Verdicts rendered: 12/12
1187
+ Auto-approved (unreviewed): 0 fraud ad(s) missed
1188
+ Correct decisions: 4/12
1189
+
1190
+
1191
+ ## Step 17
1192
+
1193
+ ### Observation (sent to LLM)
1194
+ ```
1195
+ Queue: Task: Sophisticated Fraud Under Budget Pressure (medium)
1196
+ Total ads: 12 | Reviewed: 12 | Pending: 0 | Budget remaining: 25 actions | Step: 17
1197
+
1198
+ Current Ad:
1199
+
1200
+ Feedback: Episode complete. Grader score: 0.684/1.000
1201
+ Verdicts rendered: 12/12
1202
+ Auto-approved (unreviewed): 0 fraud ad(s) missed
1203
+ Correct decisions: 4/12
1204
+
1205
+ Available ads:
1206
+
1207
+ Verdicts: ad_001: approve (confidence: 0.90)
1208
+ ad_002: reject (confidence: 0.95)
1209
+ ad_003: escalate (confidence: 0.30)
1210
+ ad_004: escalate (confidence: 0.30)
1211
+ ad_005: escalate (confidence: 0.30)
1212
+ ad_006: escalate (confidence: 0.30)
1213
+ ad_007: escalate (confidence: 0.30)
1214
+ ad_008: escalate (confidence: 0.30)
1215
+ ad_009: escalate (confidence: 0.30)
1216
+ ad_010: escalate (confidence: 0.30)
1217
+ ad_011: escalate (con... [truncated]
1218
+
1219
+ Findings:
1220
+ [ad_001 / advertiser_history]
1221
+ Advertiser: FitVisit (Verified Business)
1222
+ Account age: 1558 days
1223
+ Country: Canada
1224
+ Total historical spend: $55,594.09
1225
+ Ads submitted in last 30 days: 13
1226
+ Historical approval rate: 91%
1227
+ Payment method: credit_card (ID: pmt_865179)
1228
+
1229
+ [ad_001 / payment_method]
1230
+ Payment Method Analysis for ad_001:
1231
+ Method type: credit_card
1232
+ Payment ID: pmt_865179
1233
+ No other accounts in the current queue share this payment method.
1234
+
1235
+ [ad_002 / landing_page]
1236
+ Domain: shop4020.top
1237
+ Domain age: 11 days
1238
+ SSL: NO SSL / expired certificate
1239
+ Registrar: Njalla (privacy)
1240
+ Redirect chain: shop4020.top -> red... [truncated]
1241
+ ```
1242
+
1243
+ ### LLM Response
1244
+ ```json
1245
+ {}
1246
+ ```
1247
+
1248
+
1249
+ ---
1250
+ ## Result
1251
+ **Score: 0.684 | Steps: 17 | Verdicts: 12/12**
outputs/task_3_conversation.md ADDED
@@ -0,0 +1,1057 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Episode Log — task_3
2
+
3
+
4
+ ## Step 0
5
+
6
+ ### Observation (sent to LLM)
7
+ ```
8
+ Queue: Task: Coordinated Fraud Network Detection (hard)
9
+ Total ads: 20 | Reviewed: 0 | Pending: 20 | Budget remaining: 40 actions | Step: 0
10
+
11
+ Current Ad: === Ad in Focus: ad_001 ===
12
+ Category: saas
13
+ Ad copy: "Automate your invoicing in minutes. Trusted by 50,000+ small businesses. InvoiceSimple."
14
+ Targeting: Small business owners, interests: finance, accounting
15
+ Initial risk signals: None
16
+ Investigations completed: None yet
17
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
18
+
19
+ Feedback: Episode started. Review the ad queue and begin your investigation.
20
+
21
+ Available ads: ad_001, ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
22
+ ```
23
+
24
+ ### LLM Response
25
+ ```json
26
+ {}
27
+ ```
28
+
29
+ ### Parsed Action [FALLBACK]
30
+ ```json
31
+ {
32
+ "action_type": "verdict",
33
+ "ad_id": "ad_001",
34
+ "verdict": "escalate",
35
+ "confidence": 0.3
36
+ }
37
+ ```
38
+
39
+ ### Environment Response
40
+ - **Reward:** `+0.00`
41
+ - **Done:** `False`
42
+
43
+ - **Feedback:** Verdict recorded for ad_001: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
44
+
45
+
46
+ ## Step 1
47
+
48
+ ### Observation (sent to LLM)
49
+ ```
50
+ Queue: Task: Coordinated Fraud Network Detection (hard)
51
+ Total ads: 20 | Reviewed: 1 | Pending: 19 | Budget remaining: 40 actions | Step: 1
52
+
53
+ Current Ad: === Ad in Focus: ad_002 ===
54
+ Category: advance_fee
55
+ Ad copy: "You've won the UK National Lottery! Send $100 for tax processing to receive your $1.5M prize."
56
+ Targeting: Adults 25-70, interests: lottery, gambling, luck
57
+ Initial risk signals: advance fee request, unsolicited money offer, urgency pressure, request for personal information
58
+ Investigations completed: None yet
59
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
60
+
61
+ Feedback: Verdict recorded for ad_001: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
62
+
63
+ Available ads: ad_002, ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
64
+
65
+ Verdicts: ad_001: escalate (confidence: 0.30)
66
+ ```
67
+
68
+ ### LLM Response
69
+ ```json
70
+ {}
71
+ ```
72
+
73
+ ### Parsed Action [FALLBACK]
74
+ ```json
75
+ {
76
+ "action_type": "verdict",
77
+ "ad_id": "ad_002",
78
+ "verdict": "escalate",
79
+ "confidence": 0.3
80
+ }
81
+ ```
82
+
83
+ ### Environment Response
84
+ - **Reward:** `+0.00`
85
+ - **Done:** `False`
86
+
87
+ - **Feedback:** Verdict recorded for ad_002: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
88
+
89
+
90
+ ## Step 2
91
+
92
+ ### Observation (sent to LLM)
93
+ ```
94
+ Queue: Task: Coordinated Fraud Network Detection (hard)
95
+ Total ads: 20 | Reviewed: 2 | Pending: 18 | Budget remaining: 40 actions | Step: 2
96
+
97
+ Current Ad: === Ad in Focus: ad_003 ===
98
+ Category: advance_fee
99
+ Ad copy: "Congratulations! You've inherited $2.8 million from a distant relative. Pay $200 processing fee to claim."
100
+ Targeting: Adults 30-70, interests: finance, inheritance
101
+ Initial risk signals: advance fee request, unsolicited money offer, urgency pressure, request for personal information
102
+ Investigations completed: None yet
103
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
104
+
105
+ Feedback: Verdict recorded for ad_002: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
106
+
107
+ Available ads: ad_003, ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
108
+
109
+ Verdicts: ad_001: escalate (confidence: 0.30)
110
+ ad_002: escalate (confidence: 0.30)
111
+ ```
112
+
113
+ ### LLM Response
114
+ ```json
115
+ {}
116
+ ```
117
+
118
+ ### Parsed Action [FALLBACK]
119
+ ```json
120
+ {
121
+ "action_type": "verdict",
122
+ "ad_id": "ad_003",
123
+ "verdict": "escalate",
124
+ "confidence": 0.3
125
+ }
126
+ ```
127
+
128
+ ### Environment Response
129
+ - **Reward:** `+0.00`
130
+ - **Done:** `False`
131
+
132
+ - **Feedback:** Verdict recorded for ad_003: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
133
+
134
+
135
+ ## Step 3
136
+
137
+ ### Observation (sent to LLM)
138
+ ```
139
+ Queue: Task: Coordinated Fraud Network Detection (hard)
140
+ Total ads: 20 | Reviewed: 3 | Pending: 17 | Budget remaining: 40 actions | Step: 3
141
+
142
+ Current Ad: === Ad in Focus: ad_004 ===
143
+ Category: network_crypto
144
+ Ad copy: "Crypto tax software — auto-import from 50+ exchanges. CPA-approved reports. TaxChain Solutions."
145
+ Targeting: Adults 25-55, interests: crypto, tax, finance
146
+ Initial risk signals: None
147
+ Investigations completed: None yet
148
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
149
+
150
+ Feedback: Verdict recorded for ad_003: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
151
+
152
+ Available ads: ad_004, ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
153
+
154
+ Verdicts: ad_001: escalate (confidence: 0.30)
155
+ ad_002: escalate (confidence: 0.30)
156
+ ad_003: escalate (confidence: 0.30)
157
+ ```
158
+
159
+ ### LLM Response
160
+ ```json
161
+ {}
162
+ ```
163
+
164
+ ### Parsed Action [FALLBACK]
165
+ ```json
166
+ {
167
+ "action_type": "verdict",
168
+ "ad_id": "ad_004",
169
+ "verdict": "escalate",
170
+ "confidence": 0.3
171
+ }
172
+ ```
173
+
174
+ ### Environment Response
175
+ - **Reward:** `+0.00`
176
+ - **Done:** `False`
177
+
178
+ - **Feedback:** Verdict recorded for ad_004: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
179
+
180
+
181
+ ## Step 4
182
+
183
+ ### Observation (sent to LLM)
184
+ ```
185
+ Queue: Task: Coordinated Fraud Network Detection (hard)
186
+ Total ads: 20 | Reviewed: 4 | Pending: 16 | Budget remaining: 40 actions | Step: 4
187
+
188
+ Current Ad: === Ad in Focus: ad_005 ===
189
+ Category: fake_crypto
190
+ Ad copy: "Invest in DeFi with confidence. Our AI-powered trading platform delivers 12-15% monthly returns. Audited smart contracts. Join CryptoYield Pro."
191
+ Targeting: Men 25-45, interests: cryptocurrency, DeFi, investing
192
+ Initial risk signals: high guaranteed returns, complex financial product
193
+ Investigations completed: None yet
194
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
195
+
196
+ Feedback: Verdict recorded for ad_004: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
197
+
198
+ Available ads: ad_005, ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
199
+
200
+ Verdicts: ad_001: escalate (confidence: 0.30)
201
+ ad_002: escalate (confidence: 0.30)
202
+ ad_003: escalate (confidence: 0.30)
203
+ ad_004: escalate (confidence: 0.30)
204
+ ```
205
+
206
+ ### LLM Response
207
+ ```json
208
+ {}
209
+ ```
210
+
211
+ ### Parsed Action [FALLBACK]
212
+ ```json
213
+ {
214
+ "action_type": "verdict",
215
+ "ad_id": "ad_005",
216
+ "verdict": "escalate",
217
+ "confidence": 0.3
218
+ }
219
+ ```
220
+
221
+ ### Environment Response
222
+ - **Reward:** `+0.00`
223
+ - **Done:** `False`
224
+
225
+ - **Feedback:** Verdict recorded for ad_005: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
226
+
227
+
228
+ ## Step 5
229
+
230
+ ### Observation (sent to LLM)
231
+ ```
232
+ Queue: Task: Coordinated Fraud Network Detection (hard)
233
+ Total ads: 20 | Reviewed: 5 | Pending: 15 | Budget remaining: 40 actions | Step: 5
234
+
235
+ Current Ad: === Ad in Focus: ad_006 ===
236
+ Category: ecommerce
237
+ Ad copy: "Spring Sale — Up to 30% off home essentials. Free shipping on orders over $50. Shop now at HomeNest."
238
+ Targeting: Women 25-54, interests: home decor, shopping
239
+ Initial risk signals: None
240
+ Investigations completed: None yet
241
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
242
+
243
+ Feedback: Verdict recorded for ad_005: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
244
+
245
+ Available ads: ad_006, ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
246
+
247
+ Verdicts: ad_001: escalate (confidence: 0.30)
248
+ ad_002: escalate (confidence: 0.30)
249
+ ad_003: escalate (confidence: 0.30)
250
+ ad_004: escalate (confidence: 0.30)
251
+ ad_005: escalate (confidence: 0.30)
252
+ ```
253
+
254
+ ### LLM Response
255
+ ```json
256
+ {}
257
+ ```
258
+
259
+ ### Parsed Action [FALLBACK]
260
+ ```json
261
+ {
262
+ "action_type": "verdict",
263
+ "ad_id": "ad_006",
264
+ "verdict": "escalate",
265
+ "confidence": 0.3
266
+ }
267
+ ```
268
+
269
+ ### Environment Response
270
+ - **Reward:** `+0.00`
271
+ - **Done:** `False`
272
+
273
+ - **Feedback:** Verdict recorded for ad_006: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
274
+
275
+
276
+ ## Step 6
277
+
278
+ ### Observation (sent to LLM)
279
+ ```
280
+ Queue: Task: Coordinated Fraud Network Detection (hard)
281
+ Total ads: 20 | Reviewed: 6 | Pending: 14 | Budget remaining: 40 actions | Step: 6
282
+
283
+ Current Ad: === Ad in Focus: ad_007 ===
284
+ Category: gray_area_supplements
285
+ Ad copy: "SlimBiome Pro — proprietary probiotic blend for healthy weight management. Featured in Health Magazine. GMP certified."
286
+ Targeting: Adults 30-60, interests: weight loss, probiotics, gut health
287
+ Initial risk signals: supplement with strong efficacy claims, medical-sounding language
288
+ Investigations completed: None yet
289
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
290
+
291
+ Feedback: Verdict recorded for ad_006: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
292
+
293
+ Available ads: ad_007, ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
294
+
295
+ Verdicts: ad_001: escalate (confidence: 0.30)
296
+ ad_002: escalate (confidence: 0.30)
297
+ ad_003: escalate (confidence: 0.30)
298
+ ad_004: escalate (confidence: 0.30)
299
+ ad_005: escalate (confidence: 0.30)
300
+ ad_006: escalate (confidence: 0.30)
301
+ ```
302
+
303
+ ### LLM Response
304
+ ```json
305
+ {}
306
+ ```
307
+
308
+ ### Parsed Action [FALLBACK]
309
+ ```json
310
+ {
311
+ "action_type": "verdict",
312
+ "ad_id": "ad_007",
313
+ "verdict": "escalate",
314
+ "confidence": 0.3
315
+ }
316
+ ```
317
+
318
+ ### Environment Response
319
+ - **Reward:** `+0.00`
320
+ - **Done:** `False`
321
+
322
+ - **Feedback:** Verdict recorded for ad_007: escalate (confidence: 0.30). Correct decision. Reward: +0.15
323
+
324
+
325
+ ## Step 7
326
+
327
+ ### Observation (sent to LLM)
328
+ ```
329
+ Queue: Task: Coordinated Fraud Network Detection (hard)
330
+ Total ads: 20 | Reviewed: 7 | Pending: 13 | Budget remaining: 40 actions | Step: 7
331
+
332
+ Current Ad: === Ad in Focus: ad_008 ===
333
+ Category: celebrity_endorsement_fraud
334
+ Ad copy: "Oprah's favorite weight loss secret finally revealed. 'I lost 40 pounds without dieting.' Doctor-approved method."
335
+ Targeting: Women 25-60, interests: weight loss, Oprah, wellness
336
+ Initial risk signals: celebrity name used without verification, urgency/exclusivity language
337
+ Investigations completed: None yet
338
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
339
+
340
+ Feedback: Verdict recorded for ad_007: escalate (confidence: 0.30). Correct decision. Reward: +0.15
341
+
342
+ Available ads: ad_008, ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
343
+
344
+ Verdicts: ad_001: escalate (confidence: 0.30)
345
+ ad_002: escalate (confidence: 0.30)
346
+ ad_003: escalate (confidence: 0.30)
347
+ ad_004: escalate (confidence: 0.30)
348
+ ad_005: escalate (confidence: 0.30)
349
+ ad_006: escalate (confidence: 0.30)
350
+ ad_007: escalate (confidence: 0.30)
351
+ ```
352
+
353
+ ### LLM Response
354
+ ```json
355
+ {}
356
+ ```
357
+
358
+ ### Parsed Action [FALLBACK]
359
+ ```json
360
+ {
361
+ "action_type": "verdict",
362
+ "ad_id": "ad_008",
363
+ "verdict": "escalate",
364
+ "confidence": 0.3
365
+ }
366
+ ```
367
+
368
+ ### Environment Response
369
+ - **Reward:** `+0.00`
370
+ - **Done:** `False`
371
+
372
+ - **Feedback:** Verdict recorded for ad_008: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
373
+
374
+
375
+ ## Step 8
376
+
377
+ ### Observation (sent to LLM)
378
+ ```
379
+ Queue: Task: Coordinated Fraud Network Detection (hard)
380
+ Total ads: 20 | Reviewed: 8 | Pending: 12 | Budget remaining: 40 actions | Step: 8
381
+
382
+ Current Ad: === Ad in Focus: ad_009 ===
383
+ Category: education
384
+ Ad copy: "Learn Python programming in 12 weeks. Live instructor, project-based curriculum. CodeAcademy Pro."
385
+ Targeting: Adults 18-35, interests: programming, tech careers
386
+ Initial risk signals: None
387
+ Investigations completed: None yet
388
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
389
+
390
+ Feedback: Verdict recorded for ad_008: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
391
+
392
+ Available ads: ad_009, ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
393
+
394
+ Verdicts: ad_001: escalate (confidence: 0.30)
395
+ ad_002: escalate (confidence: 0.30)
396
+ ad_003: escalate (confidence: 0.30)
397
+ ad_004: escalate (confidence: 0.30)
398
+ ad_005: escalate (confidence: 0.30)
399
+ ad_006: escalate (confidence: 0.30)
400
+ ad_007: escalate (confidence: 0.30)
401
+ ad_008: escalate (confidence: 0.30)
402
+ ```
403
+
404
+ ### LLM Response
405
+ ```json
406
+ {}
407
+ ```
408
+
409
+ ### Parsed Action [FALLBACK]
410
+ ```json
411
+ {
412
+ "action_type": "verdict",
413
+ "ad_id": "ad_009",
414
+ "verdict": "escalate",
415
+ "confidence": 0.3
416
+ }
417
+ ```
418
+
419
+ ### Environment Response
420
+ - **Reward:** `+0.00`
421
+ - **Done:** `False`
422
+
423
+ - **Feedback:** Verdict recorded for ad_009: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
424
+
425
+
426
+ ## Step 9
427
+
428
+ ### Observation (sent to LLM)
429
+ ```
430
+ Queue: Task: Coordinated Fraud Network Detection (hard)
431
+ Total ads: 20 | Reviewed: 9 | Pending: 11 | Budget remaining: 40 actions | Step: 9
432
+
433
+ Current Ad: === Ad in Focus: ad_010 ===
434
+ Category: fake_giveaway
435
+ Ad copy: "EXCLUSIVE: Win a Tesla Model 3! Enter your email NOW! Drawing ends tonight!!!"
436
+ Targeting: Adults 18-55, interests: cars, Tesla, electric vehicles
437
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
438
+ Investigations completed: None yet
439
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
440
+
441
+ Feedback: Verdict recorded for ad_009: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
442
+
443
+ Available ads: ad_010, ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
444
+
445
+ Verdicts: ad_001: escalate (confidence: 0.30)
446
+ ad_002: escalate (confidence: 0.30)
447
+ ad_003: escalate (confidence: 0.30)
448
+ ad_004: escalate (confidence: 0.30)
449
+ ad_005: escalate (confidence: 0.30)
450
+ ad_006: escalate (confidence: 0.30)
451
+ ad_007: escalate (confidence: 0.30)
452
+ ad_008: escalate (confidence: 0.30)
453
+ ad_009: escalate (confidence: 0.30)
454
+ ```
455
+
456
+ ### LLM Response
457
+ ```json
458
+ {}
459
+ ```
460
+
461
+ ### Parsed Action [FALLBACK]
462
+ ```json
463
+ {
464
+ "action_type": "verdict",
465
+ "ad_id": "ad_010",
466
+ "verdict": "escalate",
467
+ "confidence": 0.3
468
+ }
469
+ ```
470
+
471
+ ### Environment Response
472
+ - **Reward:** `+0.00`
473
+ - **Done:** `False`
474
+
475
+ - **Feedback:** Verdict recorded for ad_010: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
476
+
477
+
478
+ ## Step 10
479
+
480
+ ### Observation (sent to LLM)
481
+ ```
482
+ Queue: Task: Coordinated Fraud Network Detection (hard)
483
+ Total ads: 20 | Reviewed: 10 | Pending: 10 | Budget remaining: 40 actions | Step: 10
484
+
485
+ Current Ad: === Ad in Focus: ad_011 ===
486
+ Category: ecommerce
487
+ Ad copy: "Handcrafted leather bags — designed to last. Each piece unique. ArtisanHide.com"
488
+ Targeting: Adults 25-55, interests: fashion, luxury goods
489
+ Initial risk signals: None
490
+ Investigations completed: None yet
491
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
492
+
493
+ Feedback: Verdict recorded for ad_010: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
494
+
495
+ Available ads: ad_011, ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
496
+
497
+ Verdicts: ad_001: escalate (confidence: 0.30)
498
+ ad_002: escalate (confidence: 0.30)
499
+ ad_003: escalate (confidence: 0.30)
500
+ ad_004: escalate (confidence: 0.30)
501
+ ad_005: escalate (confidence: 0.30)
502
+ ad_006: escalate (confidence: 0.30)
503
+ ad_007: escalate (confidence: 0.30)
504
+ ad_008: escalate (confidence: 0.30)
505
+ ad_009: escalate (confidence: 0.30)
506
+ ad_010: escalate (confidence: 0.30)
507
+ ```
508
+
509
+ ### LLM Response
510
+ ```json
511
+ {}
512
+ ```
513
+
514
+ ### Parsed Action [FALLBACK]
515
+ ```json
516
+ {
517
+ "action_type": "verdict",
518
+ "ad_id": "ad_011",
519
+ "verdict": "escalate",
520
+ "confidence": 0.3
521
+ }
522
+ ```
523
+
524
+ ### Environment Response
525
+ - **Reward:** `+0.00`
526
+ - **Done:** `False`
527
+
528
+ - **Feedback:** Verdict recorded for ad_011: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
529
+
530
+
531
+ ## Step 11
532
+
533
+ ### Observation (sent to LLM)
534
+ ```
535
+ Queue: Task: Coordinated Fraud Network Detection (hard)
536
+ Total ads: 20 | Reviewed: 11 | Pending: 9 | Budget remaining: 40 actions | Step: 11
537
+
538
+ Current Ad: === Ad in Focus: ad_012 ===
539
+ Category: gray_area_supplements
540
+ Ad copy: "Joint Restore Plus — advanced collagen peptide formula. Thousands of 5-star reviews. Orthopedist recommended."
541
+ Targeting: Adults 45-75, interests: joint health, supplements, arthritis
542
+ Initial risk signals: supplement with strong efficacy claims, medical-sounding language
543
+ Investigations completed: None yet
544
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
545
+
546
+ Feedback: Verdict recorded for ad_011: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
547
+
548
+ Available ads: ad_012, ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
549
+
550
+ Verdicts: ad_001: escalate (confidence: 0.30)
551
+ ad_002: escalate (confidence: 0.30)
552
+ ad_003: escalate (confidence: 0.30)
553
+ ad_004: escalate (confidence: 0.30)
554
+ ad_005: escalate (confidence: 0.30)
555
+ ad_006: escalate (confidence: 0.30)
556
+ ad_007: escalate (confidence: 0.30)
557
+ ad_008: escalate (confidence: 0.30)
558
+ ad_009: escalate (confidence: 0.30)
559
+ ad_010: escalate (confidence: 0.30)
560
+ ad_011: escalate (... [truncated]
561
+ ```
562
+
563
+ ### LLM Response
564
+ ```json
565
+ {}
566
+ ```
567
+
568
+ ### Parsed Action [FALLBACK]
569
+ ```json
570
+ {
571
+ "action_type": "verdict",
572
+ "ad_id": "ad_012",
573
+ "verdict": "escalate",
574
+ "confidence": 0.3
575
+ }
576
+ ```
577
+
578
+ ### Environment Response
579
+ - **Reward:** `+0.00`
580
+ - **Done:** `False`
581
+
582
+ - **Feedback:** Verdict recorded for ad_012: escalate (confidence: 0.30). Correct decision. Reward: +0.15
583
+
584
+
585
+ ## Step 12
586
+
587
+ ### Observation (sent to LLM)
588
+ ```
589
+ Queue: Task: Coordinated Fraud Network Detection (hard)
590
+ Total ads: 20 | Reviewed: 12 | Pending: 8 | Budget remaining: 40 actions | Step: 12
591
+
592
+ Current Ad: === Ad in Focus: ad_013 ===
593
+ Category: gray_area_supplements
594
+ Ad copy: "NeuroPeak — clinically studied nootropic for enhanced focus and memory. 30-day money-back guarantee. FDA-registered facility."
595
+ Targeting: Adults 25-55, interests: nootropics, productivity, brain health
596
+ Initial risk signals: supplement with strong efficacy claims, medical-sounding language
597
+ Investigations completed: None yet
598
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
599
+
600
+ Feedback: Verdict recorded for ad_012: escalate (confidence: 0.30). Correct decision. Reward: +0.15
601
+
602
+ Available ads: ad_013, ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
603
+
604
+ Verdicts: ad_001: escalate (confidence: 0.30)
605
+ ad_002: escalate (confidence: 0.30)
606
+ ad_003: escalate (confidence: 0.30)
607
+ ad_004: escalate (confidence: 0.30)
608
+ ad_005: escalate (confidence: 0.30)
609
+ ad_006: escalate (confidence: 0.30)
610
+ ad_007: escalate (confidence: 0.30)
611
+ ad_008: escalate (confidence: 0.30)
612
+ ad_009: escalate (confidence: 0.30)
613
+ ad_010: escalate (confidence: 0.30)
614
+ ad_011: escalate (... [truncated]
615
+ ```
616
+
617
+ ### LLM Response
618
+ ```json
619
+ {}
620
+ ```
621
+
622
+ ### Parsed Action [FALLBACK]
623
+ ```json
624
+ {
625
+ "action_type": "verdict",
626
+ "ad_id": "ad_013",
627
+ "verdict": "escalate",
628
+ "confidence": 0.3
629
+ }
630
+ ```
631
+
632
+ ### Environment Response
633
+ - **Reward:** `+0.00`
634
+ - **Done:** `False`
635
+
636
+ - **Feedback:** Verdict recorded for ad_013: escalate (confidence: 0.30). Correct decision. Reward: +0.15
637
+
638
+
639
+ ## Step 13
640
+
641
+ ### Observation (sent to LLM)
642
+ ```
643
+ Queue: Task: Coordinated Fraud Network Detection (hard)
644
+ Total ads: 20 | Reviewed: 13 | Pending: 7 | Budget remaining: 40 actions | Step: 13
645
+
646
+ Current Ad: === Ad in Focus: ad_014 ===
647
+ Category: fake_giveaway
648
+ Ad copy: "CONGRATULATIONS!! You've been selected to win a FREE iPhone 16 Pro!! Click NOW before offer expires!!!"
649
+ Targeting: All adults 18+, broad targeting, worldwide
650
+ Initial risk signals: excessive urgency language, too-good-to-be-true offer, excessive punctuation/caps, no purchase necessary claims
651
+ Investigations completed: None yet
652
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
653
+
654
+ Feedback: Verdict recorded for ad_013: escalate (confidence: 0.30). Correct decision. Reward: +0.15
655
+
656
+ Available ads: ad_014, ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
657
+
658
+ Verdicts: ad_001: escalate (confidence: 0.30)
659
+ ad_002: escalate (confidence: 0.30)
660
+ ad_003: escalate (confidence: 0.30)
661
+ ad_004: escalate (confidence: 0.30)
662
+ ad_005: escalate (confidence: 0.30)
663
+ ad_006: escalate (confidence: 0.30)
664
+ ad_007: escalate (confidence: 0.30)
665
+ ad_008: escalate (confidence: 0.30)
666
+ ad_009: escalate (confidence: 0.30)
667
+ ad_010: escalate (confidence: 0.30)
668
+ ad_011: escalate (... [truncated]
669
+ ```
670
+
671
+ ### LLM Response
672
+ ```json
673
+ {}
674
+ ```
675
+
676
+ ### Parsed Action [FALLBACK]
677
+ ```json
678
+ {
679
+ "action_type": "verdict",
680
+ "ad_id": "ad_014",
681
+ "verdict": "escalate",
682
+ "confidence": 0.3
683
+ }
684
+ ```
685
+
686
+ ### Environment Response
687
+ - **Reward:** `+0.00`
688
+ - **Done:** `False`
689
+
690
+ - **Feedback:** Verdict recorded for ad_014: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
691
+
692
+
693
+ ## Step 14
694
+
695
+ ### Observation (sent to LLM)
696
+ ```
697
+ Queue: Task: Coordinated Fraud Network Detection (hard)
698
+ Total ads: 20 | Reviewed: 14 | Pending: 6 | Budget remaining: 40 actions | Step: 14
699
+
700
+ Current Ad: === Ad in Focus: ad_015 ===
701
+ Category: gray_area_supplements
702
+ Ad copy: "TestoMax Elite — natural testosterone support for men over 40. Doctor-formulated. Published clinical trial results."
703
+ Targeting: Men 40-65, interests: men's health, testosterone, fitness
704
+ Initial risk signals: supplement with strong efficacy claims, medical-sounding language
705
+ Investigations completed: None yet
706
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
707
+
708
+ Feedback: Verdict recorded for ad_014: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
709
+
710
+ Available ads: ad_015, ad_016, ad_017, ad_018, ad_019, ad_020
711
+
712
+ Verdicts: ad_001: escalate (confidence: 0.30)
713
+ ad_002: escalate (confidence: 0.30)
714
+ ad_003: escalate (confidence: 0.30)
715
+ ad_004: escalate (confidence: 0.30)
716
+ ad_005: escalate (confidence: 0.30)
717
+ ad_006: escalate (confidence: 0.30)
718
+ ad_007: escalate (confidence: 0.30)
719
+ ad_008: escalate (confidence: 0.30)
720
+ ad_009: escalate (confidence: 0.30)
721
+ ad_010: escalate (confidence: 0.30)
722
+ ad_011: escalate (... [truncated]
723
+ ```
724
+
725
+ ### LLM Response
726
+ ```json
727
+ {}
728
+ ```
729
+
730
+ ### Parsed Action [FALLBACK]
731
+ ```json
732
+ {
733
+ "action_type": "verdict",
734
+ "ad_id": "ad_015",
735
+ "verdict": "escalate",
736
+ "confidence": 0.3
737
+ }
738
+ ```
739
+
740
+ ### Environment Response
741
+ - **Reward:** `+0.00`
742
+ - **Done:** `False`
743
+
744
+ - **Feedback:** Verdict recorded for ad_015: escalate (confidence: 0.30). Correct decision. Reward: +0.15
745
+
746
+
747
+ ## Step 15
748
+
749
+ ### Observation (sent to LLM)
750
+ ```
751
+ Queue: Task: Coordinated Fraud Network Detection (hard)
752
+ Total ads: 20 | Reviewed: 15 | Pending: 5 | Budget remaining: 40 actions | Step: 15
753
+
754
+ Current Ad: === Ad in Focus: ad_016 ===
755
+ Category: ecommerce
756
+ Ad copy: "Handcrafted leather bags — designed to last. Each piece unique. ArtisanHide.com"
757
+ Targeting: Adults 25-55, interests: fashion, luxury goods
758
+ Initial risk signals: None
759
+ Investigations completed: None yet
760
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
761
+
762
+ Feedback: Verdict recorded for ad_015: escalate (confidence: 0.30). Correct decision. Reward: +0.15
763
+
764
+ Available ads: ad_016, ad_017, ad_018, ad_019, ad_020
765
+
766
+ Verdicts: ad_001: escalate (confidence: 0.30)
767
+ ad_002: escalate (confidence: 0.30)
768
+ ad_003: escalate (confidence: 0.30)
769
+ ad_004: escalate (confidence: 0.30)
770
+ ad_005: escalate (confidence: 0.30)
771
+ ad_006: escalate (confidence: 0.30)
772
+ ad_007: escalate (confidence: 0.30)
773
+ ad_008: escalate (confidence: 0.30)
774
+ ad_009: escalate (confidence: 0.30)
775
+ ad_010: escalate (confidence: 0.30)
776
+ ad_011: escalate (... [truncated]
777
+ ```
778
+
779
+ ### LLM Response
780
+ ```json
781
+ {}
782
+ ```
783
+
784
+ ### Parsed Action [FALLBACK]
785
+ ```json
786
+ {
787
+ "action_type": "verdict",
788
+ "ad_id": "ad_016",
789
+ "verdict": "escalate",
790
+ "confidence": 0.3
791
+ }
792
+ ```
793
+
794
+ ### Environment Response
795
+ - **Reward:** `+0.00`
796
+ - **Done:** `False`
797
+
798
+ - **Feedback:** Verdict recorded for ad_016: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
799
+
800
+
801
+ ## Step 16
802
+
803
+ ### Observation (sent to LLM)
804
+ ```
805
+ Queue: Task: Coordinated Fraud Network Detection (hard)
806
+ Total ads: 20 | Reviewed: 16 | Pending: 4 | Budget remaining: 40 actions | Step: 16
807
+
808
+ Current Ad: === Ad in Focus: ad_017 ===
809
+ Category: miracle_cure
810
+ Ad copy: "Reverse aging in 14 days! Scientists discover fountain of youth pill! Limited supply!"
811
+ Targeting: Adults 35-65, interests: anti-aging, beauty, health
812
+ Initial risk signals: unsubstantiated medical claims, conspiracy language ('they don't want you to know'), impossible health promises, urgency/scarcity tactics
813
+ Investigations completed: None yet
814
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
815
+
816
+ Feedback: Verdict recorded for ad_016: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
817
+
818
+ Available ads: ad_017, ad_018, ad_019, ad_020
819
+
820
+ Verdicts: ad_001: escalate (confidence: 0.30)
821
+ ad_002: escalate (confidence: 0.30)
822
+ ad_003: escalate (confidence: 0.30)
823
+ ad_004: escalate (confidence: 0.30)
824
+ ad_005: escalate (confidence: 0.30)
825
+ ad_006: escalate (confidence: 0.30)
826
+ ad_007: escalate (confidence: 0.30)
827
+ ad_008: escalate (confidence: 0.30)
828
+ ad_009: escalate (confidence: 0.30)
829
+ ad_010: escalate (confidence: 0.30)
830
+ ad_011: escalate (... [truncated]
831
+ ```
832
+
833
+ ### LLM Response
834
+ ```json
835
+ {}
836
+ ```
837
+
838
+ ### Parsed Action [FALLBACK]
839
+ ```json
840
+ {
841
+ "action_type": "verdict",
842
+ "ad_id": "ad_017",
843
+ "verdict": "escalate",
844
+ "confidence": 0.3
845
+ }
846
+ ```
847
+
848
+ ### Environment Response
849
+ - **Reward:** `+0.00`
850
+ - **Done:** `False`
851
+
852
+ - **Feedback:** Verdict recorded for ad_017: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
853
+
854
+
855
+ ## Step 17
856
+
857
+ ### Observation (sent to LLM)
858
+ ```
859
+ Queue: Task: Coordinated Fraud Network Detection (hard)
860
+ Total ads: 20 | Reviewed: 17 | Pending: 3 | Budget remaining: 40 actions | Step: 17
861
+
862
+ Current Ad: === Ad in Focus: ad_018 ===
863
+ Category: local_service
864
+ Ad copy: "Award-winning pizza delivery in Brooklyn. Family recipes since 1985. Order from Sal's Pizzeria."
865
+ Targeting: Adults 18-55, Brooklyn, interests: food delivery
866
+ Initial risk signals: None
867
+ Investigations completed: None yet
868
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
869
+
870
+ Feedback: Verdict recorded for ad_017: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
871
+
872
+ Available ads: ad_018, ad_019, ad_020
873
+
874
+ Verdicts: ad_001: escalate (confidence: 0.30)
875
+ ad_002: escalate (confidence: 0.30)
876
+ ad_003: escalate (confidence: 0.30)
877
+ ad_004: escalate (confidence: 0.30)
878
+ ad_005: escalate (confidence: 0.30)
879
+ ad_006: escalate (confidence: 0.30)
880
+ ad_007: escalate (confidence: 0.30)
881
+ ad_008: escalate (confidence: 0.30)
882
+ ad_009: escalate (confidence: 0.30)
883
+ ad_010: escalate (confidence: 0.30)
884
+ ad_011: escalate (... [truncated]
885
+ ```
886
+
887
+ ### LLM Response
888
+ ```json
889
+ {}
890
+ ```
891
+
892
+ ### Parsed Action [FALLBACK]
893
+ ```json
894
+ {
895
+ "action_type": "verdict",
896
+ "ad_id": "ad_018",
897
+ "verdict": "escalate",
898
+ "confidence": 0.3
899
+ }
900
+ ```
901
+
902
+ ### Environment Response
903
+ - **Reward:** `+0.00`
904
+ - **Done:** `False`
905
+
906
+ - **Feedback:** Verdict recorded for ad_018: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
907
+
908
+
909
+ ## Step 18
910
+
911
+ ### Observation (sent to LLM)
912
+ ```
913
+ Queue: Task: Coordinated Fraud Network Detection (hard)
914
+ Total ads: 20 | Reviewed: 18 | Pending: 2 | Budget remaining: 40 actions | Step: 18
915
+
916
+ Current Ad: === Ad in Focus: ad_019 ===
917
+ Category: network_ecommerce
918
+ Ad copy: "Robot vacuum with LiDAR mapping. Self-emptying dock. Smart home compatible. CleanBot Pro."
919
+ Targeting: Adults 25-55, interests: smart home, cleaning
920
+ Initial risk signals: None
921
+ Investigations completed: None yet
922
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
923
+
924
+ Feedback: Verdict recorded for ad_018: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
925
+
926
+ Available ads: ad_019, ad_020
927
+
928
+ Verdicts: ad_001: escalate (confidence: 0.30)
929
+ ad_002: escalate (confidence: 0.30)
930
+ ad_003: escalate (confidence: 0.30)
931
+ ad_004: escalate (confidence: 0.30)
932
+ ad_005: escalate (confidence: 0.30)
933
+ ad_006: escalate (confidence: 0.30)
934
+ ad_007: escalate (confidence: 0.30)
935
+ ad_008: escalate (confidence: 0.30)
936
+ ad_009: escalate (confidence: 0.30)
937
+ ad_010: escalate (confidence: 0.30)
938
+ ad_011: escalate (... [truncated]
939
+ ```
940
+
941
+ ### LLM Response
942
+ ```json
943
+ {}
944
+ ```
945
+
946
+ ### Parsed Action [FALLBACK]
947
+ ```json
948
+ {
949
+ "action_type": "verdict",
950
+ "ad_id": "ad_019",
951
+ "verdict": "escalate",
952
+ "confidence": 0.3
953
+ }
954
+ ```
955
+
956
+ ### Environment Response
957
+ - **Reward:** `+0.00`
958
+ - **Done:** `False`
959
+
960
+ - **Feedback:** Verdict recorded for ad_019: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
961
+
962
+
963
+ ## Step 19
964
+
965
+ ### Observation (sent to LLM)
966
+ ```
967
+ Queue: Task: Coordinated Fraud Network Detection (hard)
968
+ Total ads: 20 | Reviewed: 19 | Pending: 1 | Budget remaining: 40 actions | Step: 19
969
+
970
+ Current Ad: === Ad in Focus: ad_020 ===
971
+ Category: clone_brand
972
+ Ad copy: "PayPaI Security Alert: Your account needs verification. Update your information to avoid suspension. Secure portal."
973
+ Targeting: Adults 18-65, interests: online payments, PayPal
974
+ Initial risk signals: brand name misspelling, unofficial domain
975
+ Investigations completed: None yet
976
+ Available investigation targets: advertiser_history, landing_page, payment_method, targeting_overlap, creative_similarity
977
+
978
+ Feedback: Verdict recorded for ad_019: escalate (confidence: 0.30). Incorrect decision. Reward: -0.05
979
+
980
+ Available ads: ad_020
981
+
982
+ Verdicts: ad_001: escalate (confidence: 0.30)
983
+ ad_002: escalate (confidence: 0.30)
984
+ ad_003: escalate (confidence: 0.30)
985
+ ad_004: escalate (confidence: 0.30)
986
+ ad_005: escalate (confidence: 0.30)
987
+ ad_006: escalate (confidence: 0.30)
988
+ ad_007: escalate (confidence: 0.30)
989
+ ad_008: escalate (confidence: 0.30)
990
+ ad_009: escalate (confidence: 0.30)
991
+ ad_010: escalate (confidence: 0.30)
992
+ ad_011: escalate (... [truncated]
993
+ ```
994
+
995
+ ### LLM Response
996
+ ```json
997
+ {}
998
+ ```
999
+
1000
+ ### Parsed Action [FALLBACK]
1001
+ ```json
1002
+ {
1003
+ "action_type": "verdict",
1004
+ "ad_id": "ad_020",
1005
+ "verdict": "escalate",
1006
+ "confidence": 0.3
1007
+ }
1008
+ ```
1009
+
1010
+ ### Environment Response
1011
+ - **Reward:** `+0.00`
1012
+ - **Done:** `False`
1013
+
1014
+ - **Feedback:** Episode complete. Grader score: 0.579/1.000
1015
+ Verdicts rendered: 20/20
1016
+ Auto-approved (unreviewed): 0 fraud ad(s) missed
1017
+ Correct decisions: 4/20
1018
+
1019
+
1020
+ ## Step 20
1021
+
1022
+ ### Observation (sent to LLM)
1023
+ ```
1024
+ Queue: Task: Coordinated Fraud Network Detection (hard)
1025
+ Total ads: 20 | Reviewed: 20 | Pending: 0 | Budget remaining: 40 actions | Step: 20
1026
+
1027
+ Current Ad:
1028
+
1029
+ Feedback: Episode complete. Grader score: 0.579/1.000
1030
+ Verdicts rendered: 20/20
1031
+ Auto-approved (unreviewed): 0 fraud ad(s) missed
1032
+ Correct decisions: 4/20
1033
+
1034
+ Available ads:
1035
+
1036
+ Verdicts: ad_001: escalate (confidence: 0.30)
1037
+ ad_002: escalate (confidence: 0.30)
1038
+ ad_003: escalate (confidence: 0.30)
1039
+ ad_004: escalate (confidence: 0.30)
1040
+ ad_005: escalate (confidence: 0.30)
1041
+ ad_006: escalate (confidence: 0.30)
1042
+ ad_007: escalate (confidence: 0.30)
1043
+ ad_008: escalate (confidence: 0.30)
1044
+ ad_009: escalate (confidence: 0.30)
1045
+ ad_010: escalate (confidence: 0.30)
1046
+ ad_011: escalate (... [truncated]
1047
+ ```
1048
+
1049
+ ### LLM Response
1050
+ ```json
1051
+ {}
1052
+ ```
1053
+
1054
+
1055
+ ---
1056
+ ## Result
1057
+ **Score: 0.579 | Steps: 20 | Verdicts: 20/20**
pyproject.toml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-ad-fraud-env"
7
+ version = "0.1.0"
8
+ description = "Ad Fraud Investigation Environment for OpenEnv — real-world ad fraud detection and investigation simulation"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "openenv-core[core]>=0.2.2",
12
+ "fastapi>=0.115.0",
13
+ "pydantic>=2.0.0",
14
+ "uvicorn[standard]>=0.24.0",
15
+ "requests>=2.31.0",
16
+ "faker==33.1.0",
17
+ "networkx>=3.2",
18
+ "openai>=1.0.0",
19
+ "python-dotenv>=1.0.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest>=8.0.0",
25
+ "pytest-cov>=4.0.0",
26
+ "pytest-asyncio>=0.23.0",
27
+ ]
28
+
29
+ [project.scripts]
30
+ server = "ad_fraud_env.server.app:main"
31
+
32
+ [tool.setuptools]
33
+ packages = [
34
+ "ad_fraud_env",
35
+ "ad_fraud_env.server",
36
+ "ad_fraud_env.data",
37
+ "ad_fraud_env.graders",
38
+ ]
39
+
40
+ [tool.setuptools.package-dir]
41
+ "ad_fraud_env" = "."
42
+ "ad_fraud_env.server" = "server"
43
+ "ad_fraud_env.data" = "data"
44
+ "ad_fraud_env.graders" = "graders"
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Server package for the Ad Fraud Investigation Environment."""
server/app.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for the Ad Fraud Investigation Environment.
3
+
4
+ Creates the OpenEnv server via create_app() and registers custom HTTP
5
+ endpoints required by the hackathon: /tasks, /baseline, /grader.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Dict
15
+
16
+ from openenv.core.env_server import create_app
17
+
18
+ try:
19
+ from ..models import AdReviewAction, AdReviewObservation
20
+ except ImportError:
21
+ from models import AdReviewAction, AdReviewObservation
22
+
23
+ from .environment import AdFraudEnvironment, get_last_grader_result
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ app = create_app(
28
+ AdFraudEnvironment,
29
+ AdReviewAction,
30
+ AdReviewObservation,
31
+ env_name="ad_fraud_env",
32
+ )
33
+
34
+
35
+ # ------------------------------------------------------------------
36
+ # Custom endpoints required by the competition
37
+ # ------------------------------------------------------------------
38
+
39
+
40
+ @app.get("/tasks", tags=["Competition"])
41
+ async def tasks() -> Dict[str, Any]:
42
+ """Return the list of tasks and the action schema."""
43
+ try:
44
+ from ..data.ad_generator import TASK_CONFIGS
45
+ except ImportError:
46
+ from data.ad_generator import TASK_CONFIGS
47
+
48
+ task_list = []
49
+ for tid, cfg in TASK_CONFIGS.items():
50
+ task_list.append({
51
+ "id": cfg.task_id,
52
+ "name": cfg.name,
53
+ "difficulty": cfg.difficulty,
54
+ "queue_size": cfg.queue_size,
55
+ "action_budget": cfg.action_budget,
56
+ "description": cfg.description,
57
+ })
58
+
59
+ return {
60
+ "tasks": task_list,
61
+ "action_schema": AdReviewAction.model_json_schema(),
62
+ }
63
+
64
+
65
+ @app.get("/baseline", tags=["Competition"])
66
+ async def baseline() -> Dict[str, Any]:
67
+ """Return baseline scores, running live inference if credentials are available."""
68
+ baseline_path = Path(__file__).resolve().parent.parent / "baseline_scores.json"
69
+
70
+ has_creds = all(os.getenv(v) for v in ("API_BASE_URL", "MODEL_NAME", "HF_TOKEN"))
71
+ if has_creds:
72
+ try:
73
+ try:
74
+ from ..inference import run_baseline
75
+ except ImportError:
76
+ from inference import run_baseline
77
+ scores = run_baseline()
78
+ with open(baseline_path, "w") as f:
79
+ json.dump(scores, f, indent=2)
80
+ return scores
81
+ except Exception as e:
82
+ logger.warning("Live baseline failed, falling back to cached: %s", e)
83
+
84
+ if baseline_path.exists():
85
+ with open(baseline_path) as f:
86
+ return json.load(f)
87
+
88
+ return {
89
+ "error": "No baseline scores available. Set API_BASE_URL, MODEL_NAME, and HF_TOKEN to run live inference.",
90
+ "tasks": {},
91
+ }
92
+
93
+
94
+ @app.get("/grader", tags=["Competition"])
95
+ async def grader() -> Dict[str, Any]:
96
+ """Return grader score from the most recently completed episode."""
97
+ result = get_last_grader_result()
98
+ if not result:
99
+ return {
100
+ "error": "No completed episode. Run an episode via WebSocket first.",
101
+ "grader_score": None,
102
+ }
103
+ return result
104
+
105
+
106
+ def main() -> None:
107
+ """Entry point for direct execution."""
108
+ import uvicorn
109
+ uvicorn.run(app, host="0.0.0.0", port=8000)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
server/environment.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core Ad Fraud Investigation Environment.
3
+
4
+ Implements the OpenEnv Environment interface. The agent reviews a queue of ads,
5
+ investigates them, and renders verdicts under a budget constraint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, List, Optional, Set
12
+ from uuid import uuid4
13
+
14
+ from openenv.core.env_server.interfaces import Environment
15
+ from openenv.core.env_server.types import State
16
+
17
+ try:
18
+ from ..data.ad_generator import (
19
+ TASK_CONFIGS,
20
+ Ad,
21
+ GeneratedEpisode,
22
+ generate_episode,
23
+ )
24
+ from ..models import AdFraudState, AdReviewAction, AdReviewObservation
25
+ from ..graders.base_grader import EpisodeRecord, LinkResult, VerdictResult, grade_episode
26
+ except ImportError:
27
+ from data.ad_generator import (
28
+ TASK_CONFIGS,
29
+ Ad,
30
+ GeneratedEpisode,
31
+ generate_episode,
32
+ )
33
+ from models import AdFraudState, AdReviewAction, AdReviewObservation
34
+ from graders.base_grader import EpisodeRecord, LinkResult, VerdictResult, grade_episode
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Module-level store so the /grader endpoint can read the last score.
39
+ _last_grader_result: Dict[str, Any] = {}
40
+
41
+
42
+ def get_last_grader_result() -> Dict[str, Any]:
43
+ return dict(_last_grader_result)
44
+
45
+
46
+ class AdFraudEnvironment(
47
+ Environment[AdReviewAction, AdReviewObservation, AdFraudState]
48
+ ):
49
+ """
50
+ Ad fraud investigation environment.
51
+
52
+ Each episode is a review session: the agent processes a queue of N ads
53
+ within a limited action budget, choosing what to investigate and when
54
+ to render verdicts. Unreviewed ads auto-approve at episode end.
55
+ """
56
+
57
+ SUPPORTS_CONCURRENT_SESSIONS = True
58
+
59
+ def __init__(self) -> None:
60
+ super().__init__()
61
+ self._state = AdFraudState(episode_id=str(uuid4()), step_count=0)
62
+ self._episode: Optional[GeneratedEpisode] = None
63
+ self._verdicts: Dict[str, Dict[str, Any]] = {}
64
+ self._links: List[Dict[str, Any]] = []
65
+ self._investigations: Dict[str, List[str]] = {}
66
+ self._cumulative_reward: float = 0.0
67
+ self._done = False
68
+ self._last_feedback = ""
69
+ self._focused_ad_id: Optional[str] = None
70
+
71
+ # ------------------------------------------------------------------
72
+ # OpenEnv interface
73
+ # ------------------------------------------------------------------
74
+
75
+ def reset(
76
+ self,
77
+ seed: int | None = None,
78
+ episode_id: str | None = None,
79
+ **kwargs: Any,
80
+ ) -> AdReviewObservation:
81
+ task_id = kwargs.get("task_id", "task_1")
82
+ if task_id not in TASK_CONFIGS:
83
+ task_id = "task_1"
84
+
85
+ effective_seed = seed if seed is not None else hash(uuid4()) & 0xFFFFFFFF
86
+ self._episode = generate_episode(effective_seed, task_id)
87
+
88
+ config = self._episode.task_config
89
+ self._state = AdFraudState(
90
+ episode_id=episode_id or str(uuid4()),
91
+ step_count=0,
92
+ task_id=task_id,
93
+ total_ads=config.queue_size,
94
+ reviewed_count=0,
95
+ remaining_budget=config.action_budget,
96
+ verdicts={},
97
+ grader_score=None,
98
+ )
99
+ self._verdicts = {}
100
+ self._links = []
101
+ self._investigations = {}
102
+ self._cumulative_reward = 0.0
103
+ self._done = False
104
+ self._last_feedback = "Episode started. Review the ad queue and begin your investigation."
105
+ self._focused_ad_id = self._episode.ads[0].ad_id if self._episode.ads else None
106
+
107
+ return self._build_observation(reward=0.0, done=False)
108
+
109
+ def step(
110
+ self,
111
+ action: AdReviewAction,
112
+ timeout_s: float | None = None,
113
+ **kwargs: Any,
114
+ ) -> AdReviewObservation:
115
+ if self._done:
116
+ return self._build_observation(
117
+ reward=0.0, done=True,
118
+ feedback_override="Episode is already complete. Call reset() to start a new episode.",
119
+ )
120
+
121
+ if self._episode is None:
122
+ return self._build_observation(
123
+ reward=0.0, done=False,
124
+ feedback_override="Environment not initialized. Call reset() first.",
125
+ )
126
+
127
+ self._state.step_count += 1
128
+
129
+ ad_ids = {a.ad_id for a in self._episode.ads}
130
+ if action.ad_id not in ad_ids:
131
+ self._last_feedback = f"Invalid ad_id '{action.ad_id}'. Valid IDs: {', '.join(sorted(ad_ids))}"
132
+ return self._build_observation(reward=-0.05, done=False)
133
+
134
+ if action.action_type == "investigate":
135
+ reward = self._handle_investigate(action)
136
+ elif action.action_type == "verdict":
137
+ reward = self._handle_verdict(action)
138
+ elif action.action_type == "link_accounts":
139
+ reward = self._handle_link(action)
140
+ else:
141
+ self._last_feedback = f"Unknown action_type '{action.action_type}'."
142
+ reward = -0.05
143
+
144
+ self._cumulative_reward += reward
145
+
146
+ done = self._check_done()
147
+ if done and not self._done:
148
+ end_reward = self._handle_episode_end()
149
+ reward += end_reward
150
+ self._cumulative_reward += end_reward
151
+ self._done = True
152
+
153
+ self._state.remaining_budget = max(0, self._state.remaining_budget)
154
+ self._state.reviewed_count = len(self._verdicts)
155
+ self._state.verdicts = {
156
+ ad_id: v.get("verdict", "") for ad_id, v in self._verdicts.items()
157
+ }
158
+
159
+ return self._build_observation(reward=reward, done=self._done)
160
+
161
+ @property
162
+ def state(self) -> AdFraudState:
163
+ return self._state
164
+
165
+ # ------------------------------------------------------------------
166
+ # Action handlers
167
+ # ------------------------------------------------------------------
168
+
169
+ def _handle_investigate(self, action: AdReviewAction) -> float:
170
+ if self._state.remaining_budget <= 0:
171
+ self._last_feedback = "No budget remaining. You must render verdicts on remaining ads or end the episode."
172
+ return -0.02
173
+
174
+ if action.investigation_target is None:
175
+ self._last_feedback = "investigation_target is required for action_type='investigate'."
176
+ return -0.05
177
+
178
+ ad_id = action.ad_id
179
+ target = action.investigation_target
180
+
181
+ prev = self._investigations.setdefault(ad_id, [])
182
+ if target in prev:
183
+ self._last_feedback = (
184
+ f"You already investigated '{target}' for {ad_id}. "
185
+ "Choose a different investigation target or render a verdict."
186
+ )
187
+ return -0.02
188
+
189
+ if ad_id in self._verdicts:
190
+ self._last_feedback = f"You already rendered a verdict on {ad_id}. Choose a different ad."
191
+ return -0.02
192
+
193
+ self._state.remaining_budget -= 1
194
+ prev.append(target)
195
+ self._focused_ad_id = ad_id
196
+
197
+ findings = self._episode.investigation_data.get(ad_id, {}).get(
198
+ target, "No data available for this investigation type."
199
+ )
200
+ self._last_feedback = (
201
+ f"Investigation complete: {target} for {ad_id}.\n"
202
+ f"--- Findings ---\n{findings}"
203
+ )
204
+ return -0.02
205
+
206
+ def _handle_verdict(self, action: AdReviewAction) -> float:
207
+ ad_id = action.ad_id
208
+
209
+ if ad_id in self._verdicts:
210
+ self._last_feedback = f"You already rendered a verdict on {ad_id}."
211
+ return -0.02
212
+
213
+ if action.verdict is None:
214
+ self._last_feedback = "verdict field is required for action_type='verdict'."
215
+ return -0.05
216
+
217
+ confidence = action.confidence if action.confidence is not None else 0.5
218
+ ad = self._get_ad(ad_id)
219
+ ground_truth = ad.ground_truth_label if ad else "legit"
220
+ severity = ad.severity if ad else 0.0
221
+
222
+ self._verdicts[ad_id] = {
223
+ "verdict": action.verdict,
224
+ "confidence": confidence,
225
+ "ground_truth": ground_truth,
226
+ }
227
+
228
+ reward = self._compute_verdict_reward(action.verdict, ground_truth, severity, confidence)
229
+
230
+ pending = [a.ad_id for a in self._episode.ads if a.ad_id not in self._verdicts]
231
+ self._last_feedback = (
232
+ f"Verdict recorded for {ad_id}: {action.verdict} "
233
+ f"(confidence: {confidence:.2f}). "
234
+ f"{len(pending)} ad(s) remaining in queue."
235
+ )
236
+
237
+ if pending:
238
+ self._focused_ad_id = pending[0]
239
+
240
+ return reward
241
+
242
+ def _handle_link(self, action: AdReviewAction) -> float:
243
+ if action.linked_ad_id is None:
244
+ self._last_feedback = "linked_ad_id is required for action_type='link_accounts'."
245
+ return -0.05
246
+
247
+ ad_ids = {a.ad_id for a in self._episode.ads}
248
+ if action.linked_ad_id not in ad_ids:
249
+ self._last_feedback = f"Invalid linked_ad_id '{action.linked_ad_id}'."
250
+ return -0.05
251
+
252
+ if action.ad_id == action.linked_ad_id:
253
+ self._last_feedback = "Cannot link an ad to itself."
254
+ return -0.05
255
+
256
+ link_key = tuple(sorted([action.ad_id, action.linked_ad_id]))
257
+ existing = {tuple(sorted([l["ad_id_1"], l["ad_id_2"]])) for l in self._links}
258
+ if link_key in existing:
259
+ self._last_feedback = f"Link between {action.ad_id} and {action.linked_ad_id} already recorded."
260
+ return -0.02
261
+
262
+ is_correct = self._check_link_correct(action.ad_id, action.linked_ad_id)
263
+
264
+ self._links.append({
265
+ "ad_id_1": action.ad_id,
266
+ "ad_id_2": action.linked_ad_id,
267
+ "reason": action.link_reason or "",
268
+ "correct": is_correct,
269
+ })
270
+
271
+ self._last_feedback = (
272
+ f"Network link recorded: {action.ad_id} <-> {action.linked_ad_id}. "
273
+ f"Reason: {action.link_reason or 'not specified'}."
274
+ )
275
+
276
+ return 0.4 if is_correct else -0.25
277
+
278
+ # ------------------------------------------------------------------
279
+ # Reward computation
280
+ # ------------------------------------------------------------------
281
+
282
+ def _compute_verdict_reward(
283
+ self, verdict: str, ground_truth: str, severity: float, confidence: float
284
+ ) -> float:
285
+ if verdict == "reject" and ground_truth == "fraud":
286
+ return 0.3 + 0.1 * severity
287
+ elif verdict == "approve" and ground_truth == "legit":
288
+ return 0.1
289
+ elif verdict == "escalate" and ground_truth == "escalate":
290
+ return 0.15
291
+ elif verdict == "reject" and ground_truth == "legit":
292
+ return -0.35
293
+ elif verdict == "approve" and ground_truth == "fraud":
294
+ return -0.5
295
+ elif verdict == "escalate":
296
+ return -0.05
297
+ elif verdict == "approve" and ground_truth == "escalate":
298
+ return -0.15
299
+ elif verdict == "reject" and ground_truth == "escalate":
300
+ return -0.1
301
+ else:
302
+ return -0.05
303
+
304
+ def _handle_episode_end(self) -> float:
305
+ """Apply end-of-episode adjustments for unreviewed ads, then delegate to graders."""
306
+ unreviewed_fraud = 0
307
+ for ad in self._episode.ads:
308
+ if ad.ad_id not in self._verdicts:
309
+ self._verdicts[ad.ad_id] = {
310
+ "verdict": "approve",
311
+ "confidence": 0.0,
312
+ "ground_truth": ad.ground_truth_label,
313
+ "auto_approved": True,
314
+ }
315
+ if ad.ground_truth_label == "fraud":
316
+ unreviewed_fraud += 1
317
+
318
+ record = self._build_episode_record()
319
+ grader_score = grade_episode(record)
320
+ self._state.grader_score = grader_score
321
+
322
+ reviewed_count = len([v for v in self._verdicts.values() if not v.get("auto_approved")])
323
+ total_ads = len(self._episode.ads)
324
+ total_correct = sum(
325
+ 1 for v in self._verdicts.values()
326
+ if not v.get("auto_approved")
327
+ and (
328
+ (v["verdict"] == "reject" and v["ground_truth"] == "fraud")
329
+ or (v["verdict"] == "approve" and v["ground_truth"] == "legit")
330
+ or (v["verdict"] == "escalate" and v["ground_truth"] == "escalate")
331
+ )
332
+ )
333
+ false_positives = sum(
334
+ 1 for v in self._verdicts.values()
335
+ if not v.get("auto_approved")
336
+ and v["verdict"] == "reject" and v["ground_truth"] == "legit"
337
+ )
338
+ false_negatives = sum(
339
+ 1 for v in self._verdicts.values()
340
+ if not v.get("auto_approved")
341
+ and v["verdict"] == "approve" and v["ground_truth"] == "fraud"
342
+ )
343
+ correct_links = sum(1 for l in self._links if l.get("correct"))
344
+ incorrect_links = sum(1 for l in self._links if not l.get("correct"))
345
+
346
+ global _last_grader_result
347
+ _last_grader_result = {
348
+ "task_id": self._state.task_id,
349
+ "grader_score": grader_score,
350
+ "episode_id": self._state.episode_id,
351
+ "total_steps": self._state.step_count,
352
+ "verdicts_rendered": reviewed_count,
353
+ "correct_decisions": total_correct,
354
+ "false_positives": false_positives,
355
+ "false_negatives": false_negatives,
356
+ "auto_approved": total_ads - reviewed_count,
357
+ "unreviewed_fraud": unreviewed_fraud,
358
+ "network_links_correct": correct_links,
359
+ "network_links_incorrect": incorrect_links,
360
+ }
361
+
362
+ feedback_lines = [
363
+ f"Episode complete. Grader score: {grader_score:.3f}/1.000",
364
+ f"Verdicts rendered: {reviewed_count}/{total_ads}",
365
+ f"Correct decisions: {total_correct}/{reviewed_count}",
366
+ f"False positives (legit rejected): {false_positives}",
367
+ f"False negatives (fraud approved): {false_negatives}",
368
+ f"Unreviewed ads auto-approved: {unreviewed_fraud}",
369
+ ]
370
+ if self._links:
371
+ feedback_lines.append(
372
+ f"Network links: {correct_links} correct, {incorrect_links} incorrect"
373
+ )
374
+ self._last_feedback = "\n".join(feedback_lines)
375
+
376
+ return 0.0
377
+
378
+ def _build_episode_record(self) -> EpisodeRecord:
379
+ """Convert internal state into an EpisodeRecord for the grader."""
380
+ verdict_results = []
381
+ for ad in self._episode.ads:
382
+ v = self._verdicts.get(ad.ad_id)
383
+ if v:
384
+ verdict_results.append(VerdictResult(
385
+ ad_id=ad.ad_id,
386
+ verdict=v["verdict"],
387
+ confidence=v.get("confidence", 0.5),
388
+ ground_truth=v["ground_truth"],
389
+ auto_approved=v.get("auto_approved", False),
390
+ ))
391
+
392
+ link_results = [
393
+ LinkResult(ad_id_1=l["ad_id_1"], ad_id_2=l["ad_id_2"], correct=l["correct"])
394
+ for l in self._links
395
+ ]
396
+
397
+ ads_metadata = [
398
+ {"ad_id": ad.ad_id, "ground_truth": ad.ground_truth_label, "severity": ad.severity}
399
+ for ad in self._episode.ads
400
+ ]
401
+
402
+ return EpisodeRecord(
403
+ task_id=self._state.task_id,
404
+ total_steps=self._state.step_count,
405
+ action_budget=self._episode.task_config.action_budget,
406
+ verdicts=verdict_results,
407
+ links=link_results,
408
+ ads_metadata=ads_metadata,
409
+ n_fraud_rings=len(self._episode.fraud_rings),
410
+ ring_sizes=[len(r.member_ad_ids) for r in self._episode.fraud_rings],
411
+ )
412
+
413
+ # ------------------------------------------------------------------
414
+ # Helpers
415
+ # ------------------------------------------------------------------
416
+
417
+ def _check_done(self) -> bool:
418
+ if self._episode is None:
419
+ return True
420
+ all_reviewed = all(
421
+ ad.ad_id in self._verdicts for ad in self._episode.ads
422
+ )
423
+ steps_exhausted = self._state.step_count >= self._episode.task_config.action_budget
424
+ return all_reviewed or steps_exhausted
425
+
426
+ def _check_link_correct(self, ad_id_1: str, ad_id_2: str) -> bool:
427
+ """Check if two ads share a fraud ring."""
428
+ for ring in self._episode.fraud_rings:
429
+ if ad_id_1 in ring.member_ad_ids and ad_id_2 in ring.member_ad_ids:
430
+ return True
431
+ return False
432
+
433
+ def _get_ad(self, ad_id: str) -> Optional[Ad]:
434
+ if self._episode is None:
435
+ return None
436
+ for ad in self._episode.ads:
437
+ if ad.ad_id == ad_id:
438
+ return ad
439
+ return None
440
+
441
+ def _build_observation(
442
+ self,
443
+ reward: float,
444
+ done: bool,
445
+ feedback_override: str | None = None,
446
+ ) -> AdReviewObservation:
447
+ feedback = feedback_override or self._last_feedback
448
+
449
+ if self._episode is None:
450
+ return AdReviewObservation(
451
+ done=done,
452
+ reward=reward,
453
+ queue_summary="No episode loaded.",
454
+ current_ad_info="",
455
+ investigation_findings="",
456
+ verdict_history_summary="",
457
+ feedback=feedback,
458
+ available_ads=[],
459
+ queue_status={},
460
+ )
461
+
462
+ config = self._episode.task_config
463
+ pending = [a for a in self._episode.ads if a.ad_id not in self._verdicts]
464
+ reviewed = [a for a in self._episode.ads if a.ad_id in self._verdicts]
465
+
466
+ steps_remaining = max(0, config.action_budget - self._state.step_count)
467
+ queue_summary = (
468
+ f"Task: {config.name} ({config.difficulty})\n"
469
+ f"Total ads: {config.queue_size} | "
470
+ f"Reviewed: {len(reviewed)} | "
471
+ f"Pending: {len(pending)} | "
472
+ f"Steps remaining: {steps_remaining}/{config.action_budget} | "
473
+ f"Investigation budget: {self._state.remaining_budget} | "
474
+ f"Step: {self._state.step_count}"
475
+ )
476
+
477
+ current_ad_info = ""
478
+ if self._focused_ad_id and not done:
479
+ ad = self._get_ad(self._focused_ad_id)
480
+ if ad and ad.ad_id not in self._verdicts:
481
+ signals = ", ".join(ad.initial_risk_signals) if ad.initial_risk_signals else "None"
482
+ investigated = self._investigations.get(ad.ad_id, [])
483
+ inv_status = ", ".join(investigated) if investigated else "None yet"
484
+
485
+ # Contextual metadata visible before investigation
486
+ profile = self._episode.advertiser_profiles.get(ad.ad_id)
487
+ meta_lines = []
488
+ if profile:
489
+ meta_lines.append(f"Advertiser country: {profile.country}")
490
+ meta_lines.append(f"Account age: {profile.account_age_days} days")
491
+ if profile.account_age_days < 30:
492
+ meta_lines.append("Flag: New account (< 30 days)")
493
+ context_meta = "\n".join(meta_lines)
494
+
495
+ current_ad_info = (
496
+ f"=== Ad in Focus: {ad.ad_id} ===\n"
497
+ f"Category: {ad.category}\n"
498
+ f"Ad copy: \"{ad.ad_copy}\"\n"
499
+ f"Targeting: {ad.targeting_summary}\n"
500
+ f"Initial risk signals: {signals}\n"
501
+ f"{context_meta}\n"
502
+ f"Investigations completed: {inv_status}\n"
503
+ f"Available investigation targets: advertiser_history, landing_page, "
504
+ f"payment_method, targeting_overlap, creative_similarity, campaign_structure"
505
+ )
506
+
507
+ investigation_findings = ""
508
+ for ad_id, targets in self._investigations.items():
509
+ for target in targets:
510
+ finding = self._episode.investigation_data.get(ad_id, {}).get(target, "")
511
+ if finding:
512
+ investigation_findings += f"\n[{ad_id} / {target}]\n{finding}\n"
513
+
514
+ manual_verdicts = {
515
+ ad_id: v for ad_id, v in self._verdicts.items()
516
+ if not v.get("auto_approved")
517
+ }
518
+ if manual_verdicts:
519
+ counts = {"approve": 0, "reject": 0, "escalate": 0}
520
+ by_decision = {"approve": [], "reject": [], "escalate": []}
521
+ for ad_id, v in manual_verdicts.items():
522
+ counts[v["verdict"]] = counts.get(v["verdict"], 0) + 1
523
+ by_decision[v["verdict"]].append(ad_id)
524
+ summary_parts = [f"{c} {k}" for k, c in counts.items() if c > 0]
525
+ verdict_lines = [
526
+ f"Reviewed {len(manual_verdicts)} ad(s): {', '.join(summary_parts)}."
527
+ ]
528
+ for decision in ("reject", "approve", "escalate"):
529
+ if by_decision[decision]:
530
+ verdict_lines.append(
531
+ f" {decision}: {', '.join(by_decision[decision])}"
532
+ )
533
+ verdict_history_summary = "\n".join(verdict_lines)
534
+ else:
535
+ verdict_history_summary = "No verdicts yet."
536
+
537
+ available_ads = [a.ad_id for a in pending]
538
+
539
+ queue_status = {
540
+ "total_ads": config.queue_size,
541
+ "reviewed": len(reviewed),
542
+ "pending": len(pending),
543
+ "investigation_budget": self._state.remaining_budget,
544
+ "steps_remaining": steps_remaining,
545
+ "step": self._state.step_count,
546
+ "task_id": config.task_id,
547
+ }
548
+
549
+ return AdReviewObservation(
550
+ done=done,
551
+ reward=reward,
552
+ queue_summary=queue_summary,
553
+ current_ad_info=current_ad_info,
554
+ investigation_findings=investigation_findings.strip(),
555
+ verdict_history_summary=verdict_history_summary,
556
+ feedback=feedback,
557
+ available_ads=available_ads,
558
+ queue_status=queue_status,
559
+ )
server/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ fastapi>=0.115.0
3
+ pydantic>=2.0.0
4
+ uvicorn[standard]>=0.24.0
5
+ requests>=2.31.0
6
+ faker==33.1.0
7
+ networkx>=3.2
8
+ openai>=1.0.0
9
+ python-dotenv>=1.0.0
tests/__init__.py ADDED
File without changes
tests/test_data_generation.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for deterministic data generation."""
2
+
3
+ import json
4
+
5
+ from ad_fraud_env.data.ad_generator import generate_episode
6
+
7
+
8
+ class TestDeterminism:
9
+ def test_same_seed_produces_identical_output(self):
10
+ """Generate with seed=42 twice — output must be byte-identical."""
11
+ ep1 = generate_episode(seed=42, task_id="task_1")
12
+ ep2 = generate_episode(seed=42, task_id="task_1")
13
+
14
+ assert len(ep1.ads) == len(ep2.ads)
15
+ for a1, a2 in zip(ep1.ads, ep2.ads):
16
+ assert a1.ad_id == a2.ad_id
17
+ assert a1.ad_copy == a2.ad_copy
18
+ assert a1.ground_truth_label == a2.ground_truth_label
19
+
20
+ for ad_id in ep1.investigation_data:
21
+ for target in ep1.investigation_data[ad_id]:
22
+ assert (
23
+ ep1.investigation_data[ad_id][target]
24
+ == ep2.investigation_data[ad_id][target]
25
+ )
26
+
27
+ def test_different_seeds_produce_different_output(self):
28
+ ep1 = generate_episode(seed=42, task_id="task_1")
29
+ ep2 = generate_episode(seed=99, task_id="task_1")
30
+
31
+ copies_1 = {a.ad_copy for a in ep1.ads}
32
+ copies_2 = {a.ad_copy for a in ep2.ads}
33
+ assert copies_1 != copies_2
34
+
35
+ def test_task_configs_produce_correct_queue_sizes(self):
36
+ for task_id, expected_size in [("task_1", 5), ("task_2", 12), ("task_3", 20)]:
37
+ ep = generate_episode(seed=42, task_id=task_id)
38
+ assert len(ep.ads) == expected_size, f"{task_id}: expected {expected_size}, got {len(ep.ads)}"
39
+
40
+ def test_task3_has_fraud_rings(self):
41
+ ep = generate_episode(seed=42, task_id="task_3")
42
+ assert len(ep.fraud_rings) > 0, "Task 3 should have fraud rings"
43
+ for ring in ep.fraud_rings:
44
+ assert len(ring.member_ad_ids) >= 3
45
+ assert len(ring.shared_signals) >= 2
46
+ assert ring.topology in ("clique", "chain", "hub_spoke")
47
+
48
+ def test_investigation_data_exists_for_all_ads(self):
49
+ ep = generate_episode(seed=42, task_id="task_2")
50
+ expected_targets = [
51
+ "advertiser_history", "landing_page", "payment_method",
52
+ "targeting_overlap", "creative_similarity", "campaign_structure",
53
+ ]
54
+ for ad in ep.ads:
55
+ assert ad.ad_id in ep.investigation_data
56
+ for target in expected_targets:
57
+ assert target in ep.investigation_data[ad.ad_id], (
58
+ f"Missing {target} for {ad.ad_id}"
59
+ )
60
+ assert len(ep.investigation_data[ad.ad_id][target]) > 0
61
+
62
+ def test_ground_truth_distribution(self):
63
+ ep = generate_episode(seed=42, task_id="task_2")
64
+ labels = [a.ground_truth_label for a in ep.ads]
65
+ assert "fraud" in labels
66
+ assert "legit" in labels
67
+
68
+
69
+ class TestNoExplicitCrossAdReferences:
70
+ """Investigation text must not explicitly name other ad IDs."""
71
+
72
+ def test_payment_investigation_no_cross_refs(self):
73
+ ep = generate_episode(seed=42, task_id="task_3")
74
+ for ad_id, inv in ep.investigation_data.items():
75
+ text = inv["payment_method"]
76
+ for other_ad in ep.investigation_data:
77
+ if other_ad == ad_id:
78
+ continue
79
+ assert other_ad not in text, (
80
+ f"Payment investigation for {ad_id} references {other_ad}"
81
+ )
82
+
83
+ def test_targeting_investigation_no_cross_refs(self):
84
+ ep = generate_episode(seed=42, task_id="task_3")
85
+ for ad_id, inv in ep.investigation_data.items():
86
+ text = inv["targeting_overlap"]
87
+ assert "HIGH OVERLAP detected with:" not in text
88
+
89
+ def test_creative_investigation_no_cross_refs(self):
90
+ ep = generate_episode(seed=42, task_id="task_3")
91
+ for ad_id, inv in ep.investigation_data.items():
92
+ text = inv["creative_similarity"]
93
+ assert "STRONG SIMILARITY detected with:" not in text
94
+
95
+ def test_campaign_investigation_no_cross_refs(self):
96
+ ep = generate_episode(seed=42, task_id="task_3")
97
+ for ad_id, inv in ep.investigation_data.items():
98
+ text = inv["campaign_structure"]
99
+ assert "MATCH:" not in text
100
+
101
+
102
+ class TestDecoysAndRealism:
103
+ def test_advertiser_profiles_have_temporal_signals(self):
104
+ ep = generate_episode(seed=42, task_id="task_2")
105
+ for ad_id, profile in ep.advertiser_profiles.items():
106
+ assert profile.account_created_date, f"Missing created date for {ad_id}"
107
+ assert profile.spend_velocity, f"Missing spend velocity for {ad_id}"
108
+ assert profile.ad_submission_pattern, f"Missing submission pattern for {ad_id}"
109
+
110
+ def test_temporal_signals_appear_in_investigation(self):
111
+ ep = generate_episode(seed=42, task_id="task_2")
112
+ for ad_id, inv in ep.investigation_data.items():
113
+ text = inv["advertiser_history"]
114
+ assert "Account created:" in text or "Account age:" in text
115
+ assert "Spend velocity:" in text or "spend" in text.lower()
116
+
117
+ def test_ring_members_share_creation_week(self):
118
+ """Ring members should have account creation dates within 7 days of each other."""
119
+ from datetime import date
120
+ ep = generate_episode(seed=42, task_id="task_3")
121
+ for ring in ep.fraud_rings:
122
+ dates = []
123
+ for ad_id in ring.member_ad_ids:
124
+ profile = ep.advertiser_profiles[ad_id]
125
+ d = date.fromisoformat(profile.account_created_date)
126
+ dates.append(d)
127
+ if len(dates) >= 2:
128
+ spread = (max(dates) - min(dates)).days
129
+ assert spread <= 7, (
130
+ f"Ring {ring.ring_id} creation dates spread: {spread} days"
131
+ )
132
+
133
+ def test_investigation_has_whois_privacy_info(self):
134
+ ep = generate_episode(seed=42, task_id="task_2")
135
+ found_whois = False
136
+ for ad_id, inv in ep.investigation_data.items():
137
+ text = inv["landing_page"]
138
+ if "WHOIS privacy:" in text:
139
+ found_whois = True
140
+ break
141
+ assert found_whois, "At least one landing page should mention WHOIS privacy"
tests/test_environment.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the core AdFraudEnvironment."""
2
+
3
+ from ad_fraud_env.models import AdReviewAction, AdReviewObservation, AdFraudState
4
+ from ad_fraud_env.server.environment import AdFraudEnvironment
5
+
6
+
7
+ class TestReset:
8
+ def test_reset_returns_observation(self):
9
+ env = AdFraudEnvironment()
10
+ obs = env.reset(seed=42, task_id="task_1")
11
+ assert isinstance(obs, AdReviewObservation)
12
+ assert obs.done is False
13
+ assert obs.reward == 0.0
14
+ assert len(obs.available_ads) == 5
15
+
16
+ def test_reset_clears_state(self):
17
+ env = AdFraudEnvironment()
18
+ env.reset(seed=42, task_id="task_1")
19
+ env.step(AdReviewAction(
20
+ action_type="verdict", ad_id="ad_001",
21
+ verdict="approve", confidence=0.9,
22
+ ))
23
+ obs = env.reset(seed=42, task_id="task_1")
24
+ state = env.state
25
+ assert state.step_count == 0
26
+ assert state.reviewed_count == 0
27
+ assert len(obs.available_ads) == 5
28
+
29
+ def test_reset_different_tasks(self):
30
+ env = AdFraudEnvironment()
31
+ for task_id, expected in [("task_1", 5), ("task_2", 12), ("task_3", 20)]:
32
+ obs = env.reset(seed=42, task_id=task_id)
33
+ assert len(obs.available_ads) == expected
34
+
35
+
36
+ class TestStep:
37
+ def test_investigate_returns_findings(self):
38
+ env = AdFraudEnvironment()
39
+ env.reset(seed=42, task_id="task_1")
40
+ obs = env.step(AdReviewAction(
41
+ action_type="investigate",
42
+ ad_id="ad_001",
43
+ investigation_target="advertiser_history",
44
+ ))
45
+ assert obs.done is False
46
+ assert obs.reward == -0.02
47
+ assert "Advertiser" in obs.feedback or "Investigation complete" in obs.feedback
48
+
49
+ def test_verdict_correct_rejection(self):
50
+ env = AdFraudEnvironment()
51
+ env.reset(seed=42, task_id="task_1")
52
+ fraud_ads = [
53
+ a for a in env._episode.ads if a.ground_truth_label == "fraud"
54
+ ]
55
+ assert len(fraud_ads) > 0
56
+ ad = fraud_ads[0]
57
+ obs = env.step(AdReviewAction(
58
+ action_type="verdict", ad_id=ad.ad_id,
59
+ verdict="reject", confidence=0.9,
60
+ ))
61
+ assert obs.reward > 0
62
+
63
+ def test_verdict_false_negative_penalty(self):
64
+ env = AdFraudEnvironment()
65
+ env.reset(seed=42, task_id="task_1")
66
+ fraud_ads = [
67
+ a for a in env._episode.ads if a.ground_truth_label == "fraud"
68
+ ]
69
+ ad = fraud_ads[0]
70
+ obs = env.step(AdReviewAction(
71
+ action_type="verdict", ad_id=ad.ad_id,
72
+ verdict="approve", confidence=0.9,
73
+ ))
74
+ assert obs.reward < 0
75
+
76
+ def test_duplicate_verdict_rejected(self):
77
+ env = AdFraudEnvironment()
78
+ env.reset(seed=42, task_id="task_1")
79
+ env.step(AdReviewAction(
80
+ action_type="verdict", ad_id="ad_001",
81
+ verdict="approve", confidence=0.5,
82
+ ))
83
+ obs = env.step(AdReviewAction(
84
+ action_type="verdict", ad_id="ad_001",
85
+ verdict="reject", confidence=0.9,
86
+ ))
87
+ assert obs.reward == -0.02
88
+
89
+ def test_invalid_ad_id(self):
90
+ env = AdFraudEnvironment()
91
+ env.reset(seed=42, task_id="task_1")
92
+ obs = env.step(AdReviewAction(
93
+ action_type="investigate", ad_id="ad_999",
94
+ investigation_target="landing_page",
95
+ ))
96
+ assert obs.reward == -0.05
97
+ assert "Invalid" in obs.feedback
98
+
99
+ def test_episode_ends_when_all_reviewed(self):
100
+ env = AdFraudEnvironment()
101
+ obs = env.reset(seed=42, task_id="task_1")
102
+ for ad_id in list(obs.available_ads):
103
+ obs = env.step(AdReviewAction(
104
+ action_type="verdict", ad_id=ad_id,
105
+ verdict="reject", confidence=0.5,
106
+ ))
107
+ assert obs.done is True
108
+
109
+ def test_step_after_done_returns_done(self):
110
+ env = AdFraudEnvironment()
111
+ obs = env.reset(seed=42, task_id="task_1")
112
+ for ad_id in list(obs.available_ads):
113
+ obs = env.step(AdReviewAction(
114
+ action_type="verdict", ad_id=ad_id,
115
+ verdict="reject", confidence=0.5,
116
+ ))
117
+ obs = env.step(AdReviewAction(
118
+ action_type="investigate", ad_id="ad_001",
119
+ investigation_target="landing_page",
120
+ ))
121
+ assert obs.done is True
122
+ assert "already complete" in obs.feedback.lower()
123
+
124
+
125
+ class TestState:
126
+ def test_state_tracks_progress(self):
127
+ env = AdFraudEnvironment()
128
+ env.reset(seed=42, task_id="task_1")
129
+ state = env.state
130
+ assert state.task_id == "task_1"
131
+ assert state.total_ads == 5
132
+ assert state.remaining_budget == 25
133
+ assert state.step_count == 0
134
+
135
+ env.step(AdReviewAction(
136
+ action_type="investigate", ad_id="ad_001",
137
+ investigation_target="landing_page",
138
+ ))
139
+ state = env.state
140
+ assert state.step_count == 1
141
+ assert state.remaining_budget == 24
142
+
143
+ def test_grader_score_set_on_completion(self):
144
+ env = AdFraudEnvironment()
145
+ obs = env.reset(seed=42, task_id="task_1")
146
+ for ad_id in list(obs.available_ads):
147
+ env.step(AdReviewAction(
148
+ action_type="verdict", ad_id=ad_id,
149
+ verdict="reject", confidence=0.5,
150
+ ))
151
+ state = env.state
152
+ assert state.grader_score is not None
153
+ assert 0.0 <= state.grader_score <= 1.0
154
+
155
+
156
+ class TestAntiExploit:
157
+ def test_always_reject_scores_poorly(self):
158
+ """Always-reject on task_2 (5 legit / 5 fraud / 2 escalate) should be punished."""
159
+ env = AdFraudEnvironment()
160
+ obs = env.reset(seed=42, task_id="task_2")
161
+ for ad_id in list(obs.available_ads):
162
+ env.step(AdReviewAction(
163
+ action_type="verdict", ad_id=ad_id,
164
+ verdict="reject", confidence=0.9,
165
+ ))
166
+ score = env.state.grader_score
167
+ assert score < 0.7, f"Always-reject should score poorly, got {score}"
168
+
169
+ def test_always_escalate_scores_poorly(self):
170
+ env = AdFraudEnvironment()
171
+ obs = env.reset(seed=42, task_id="task_1")
172
+ for ad_id in list(obs.available_ads):
173
+ env.step(AdReviewAction(
174
+ action_type="verdict", ad_id=ad_id,
175
+ verdict="escalate", confidence=0.5,
176
+ ))
177
+ score = env.state.grader_score
178
+ assert score < 0.7, f"Always-escalate should score poorly, got {score}"
tests/test_graders.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the grading system."""
2
+
3
+ from ad_fraud_env.graders.base_grader import (
4
+ BaseGrader,
5
+ EpisodeRecord,
6
+ LinkResult,
7
+ VerdictResult,
8
+ grade_episode,
9
+ )
10
+
11
+
12
+ def _make_record(
13
+ task_id: str = "task_1",
14
+ verdicts: list | None = None,
15
+ links: list | None = None,
16
+ total_steps: int = 5,
17
+ action_budget: int = 25,
18
+ ads_metadata: list | None = None,
19
+ n_fraud_rings: int = 0,
20
+ ring_sizes: list | None = None,
21
+ ) -> EpisodeRecord:
22
+ if verdicts is None:
23
+ verdicts = []
24
+ if links is None:
25
+ links = []
26
+ if ads_metadata is None:
27
+ ads_metadata = [
28
+ {"ad_id": v.ad_id, "severity": 0.8, "ground_truth": v.ground_truth}
29
+ for v in verdicts
30
+ ]
31
+ return EpisodeRecord(
32
+ task_id=task_id,
33
+ total_steps=total_steps,
34
+ action_budget=action_budget,
35
+ verdicts=verdicts,
36
+ links=links,
37
+ ads_metadata=ads_metadata,
38
+ n_fraud_rings=n_fraud_rings,
39
+ ring_sizes=ring_sizes,
40
+ )
41
+
42
+
43
+ class TestGraderScoreRange:
44
+ def test_scores_in_valid_range(self):
45
+ verdicts = [
46
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
47
+ VerdictResult("ad_002", "approve", 0.9, "legit"),
48
+ VerdictResult("ad_003", "reject", 0.8, "fraud"),
49
+ ]
50
+ record = _make_record(verdicts=verdicts, total_steps=3)
51
+ score = grade_episode(record)
52
+ assert 0.0 <= score <= 1.0
53
+
54
+ def test_perfect_score_is_high(self):
55
+ verdicts = [
56
+ VerdictResult("ad_001", "reject", 0.95, "fraud"),
57
+ VerdictResult("ad_002", "approve", 0.95, "legit"),
58
+ VerdictResult("ad_003", "reject", 0.95, "fraud"),
59
+ VerdictResult("ad_004", "approve", 0.95, "legit"),
60
+ VerdictResult("ad_005", "reject", 0.95, "fraud"),
61
+ ]
62
+ record = _make_record(verdicts=verdicts, total_steps=5)
63
+ score = grade_episode(record)
64
+ assert score > 0.7, f"Perfect verdicts should score high, got {score}"
65
+
66
+ def test_all_wrong_scores_low(self):
67
+ verdicts = [
68
+ VerdictResult("ad_001", "approve", 0.9, "fraud"),
69
+ VerdictResult("ad_002", "reject", 0.9, "legit"),
70
+ VerdictResult("ad_003", "approve", 0.9, "fraud"),
71
+ ]
72
+ record = _make_record(verdicts=verdicts, total_steps=3)
73
+ score = grade_episode(record)
74
+ assert score < 0.3, f"All wrong verdicts should score low, got {score}"
75
+
76
+
77
+ class TestTask2Grader:
78
+ def test_calibration_bonus(self):
79
+ well_calibrated = [
80
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
81
+ VerdictResult("ad_002", "approve", 0.9, "legit"),
82
+ VerdictResult("ad_003", "reject", 0.8, "fraud"),
83
+ VerdictResult("ad_004", "approve", 0.85, "legit"),
84
+ ]
85
+ poorly_calibrated = [
86
+ VerdictResult("ad_001", "reject", 0.2, "fraud"),
87
+ VerdictResult("ad_002", "approve", 0.2, "legit"),
88
+ VerdictResult("ad_003", "reject", 0.2, "fraud"),
89
+ VerdictResult("ad_004", "approve", 0.2, "legit"),
90
+ ]
91
+ r1 = _make_record(task_id="task_2", verdicts=well_calibrated, total_steps=4, action_budget=30)
92
+ r2 = _make_record(task_id="task_2", verdicts=poorly_calibrated, total_steps=4, action_budget=30)
93
+ s1 = grade_episode(r1)
94
+ s2 = grade_episode(r2)
95
+ assert s1 >= s2, f"Well calibrated ({s1}) should score >= poorly calibrated ({s2})"
96
+
97
+
98
+ class TestTask3Grader:
99
+ def test_network_link_bonus(self):
100
+ verdicts = [
101
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
102
+ VerdictResult("ad_002", "reject", 0.9, "fraud"),
103
+ VerdictResult("ad_003", "reject", 0.9, "fraud"),
104
+ ]
105
+ links_correct = [
106
+ LinkResult("ad_001", "ad_002", True),
107
+ LinkResult("ad_002", "ad_003", True),
108
+ ]
109
+ links_wrong = [
110
+ LinkResult("ad_001", "ad_002", False),
111
+ ]
112
+
113
+ r1 = _make_record(
114
+ task_id="task_3", verdicts=verdicts, links=links_correct,
115
+ total_steps=5, action_budget=35, n_fraud_rings=1,
116
+ ring_sizes=[3],
117
+ )
118
+ r2 = _make_record(
119
+ task_id="task_3", verdicts=verdicts, links=links_wrong,
120
+ total_steps=4, action_budget=35, n_fraud_rings=1,
121
+ ring_sizes=[3],
122
+ )
123
+ s1 = grade_episode(r1)
124
+ s2 = grade_episode(r2)
125
+ assert s1 > s2, f"Correct links ({s1}) should score > wrong links ({s2})"
126
+
127
+ def test_graph_based_scoring(self):
128
+ """Task 3 grader should use edge coverage from ground truth graph."""
129
+ verdicts = [
130
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
131
+ VerdictResult("ad_002", "reject", 0.9, "fraud"),
132
+ VerdictResult("ad_003", "reject", 0.9, "fraud"),
133
+ VerdictResult("ad_004", "reject", 0.9, "fraud"),
134
+ ]
135
+ # 4 ads in a ring of 4 -> 6 ground truth edges
136
+ # Discover 3 of them
137
+ links = [
138
+ LinkResult("ad_001", "ad_002", True),
139
+ LinkResult("ad_002", "ad_003", True),
140
+ LinkResult("ad_003", "ad_004", True),
141
+ ]
142
+ r = _make_record(
143
+ task_id="task_3", verdicts=verdicts, links=links,
144
+ total_steps=7, action_budget=35, n_fraud_rings=1,
145
+ ring_sizes=[4],
146
+ )
147
+ score = grade_episode(r)
148
+ assert 0.0 <= score <= 1.0
149
+
150
+ def test_false_links_penalized(self):
151
+ """False link_accounts should reduce score."""
152
+ verdicts = [
153
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
154
+ VerdictResult("ad_002", "approve", 0.9, "legit"),
155
+ ]
156
+ no_links = _make_record(
157
+ task_id="task_3", verdicts=verdicts, links=[],
158
+ total_steps=2, action_budget=35, n_fraud_rings=1,
159
+ ring_sizes=[3],
160
+ )
161
+ false_links = _make_record(
162
+ task_id="task_3", verdicts=verdicts,
163
+ links=[LinkResult("ad_001", "ad_002", False)],
164
+ total_steps=3, action_budget=35, n_fraud_rings=1,
165
+ ring_sizes=[3],
166
+ )
167
+ s_none = grade_episode(no_links)
168
+ s_false = grade_episode(false_links)
169
+ assert s_none >= s_false, (
170
+ f"No links ({s_none}) should score >= false links ({s_false})"
171
+ )
172
+
173
+ def test_coverage_bonus(self):
174
+ """Agents that review more ads should get a coverage bonus."""
175
+ few_verdicts = [
176
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
177
+ ]
178
+ many_verdicts = [
179
+ VerdictResult("ad_001", "reject", 0.9, "fraud"),
180
+ VerdictResult("ad_002", "approve", 0.9, "legit"),
181
+ VerdictResult("ad_003", "reject", 0.9, "fraud"),
182
+ VerdictResult("ad_004", "approve", 0.9, "legit"),
183
+ ]
184
+ ads_meta = [
185
+ {"ad_id": "ad_001", "severity": 0.8, "ground_truth": "fraud"},
186
+ {"ad_id": "ad_002", "severity": 0.5, "ground_truth": "legit"},
187
+ {"ad_id": "ad_003", "severity": 0.8, "ground_truth": "fraud"},
188
+ {"ad_id": "ad_004", "severity": 0.5, "ground_truth": "legit"},
189
+ {"ad_id": "ad_005", "severity": 0.5, "ground_truth": "legit"},
190
+ ]
191
+ r_few = _make_record(
192
+ task_id="task_3", verdicts=few_verdicts, total_steps=1,
193
+ action_budget=35, ads_metadata=ads_meta, ring_sizes=[3],
194
+ )
195
+ r_many = _make_record(
196
+ task_id="task_3", verdicts=many_verdicts, total_steps=4,
197
+ action_budget=35, ads_metadata=ads_meta, ring_sizes=[3],
198
+ )
199
+ s_few = grade_episode(r_few)
200
+ s_many = grade_episode(r_many)
201
+ assert s_many > s_few, (
202
+ f"More coverage ({s_many}) should score > less coverage ({s_few})"
203
+ )
uv.lock ADDED
The diff for this file is too large to render. See raw diff