UTKARSH MISHRA commited on
Commit
e5e36db
Β·
0 Parent(s):

Initial commit: InfoClassifier project

Browse files
.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy this file to .env and fill in your keys.
2
+ # Never commit .env to version control.
3
+
4
+ # Search backends (at least one required for data collection)
5
+ SERPAPI_KEY=d6e47f7df7bd5cadb62db480f00f4c8031cf1b7910915631fefa4c154c75211e
6
+ NEWSAPI_KEY=
7
+ BRAVE_SEARCH_KEY=
8
+ GOOGLE_FACTCHECK_KEY=
9
+
10
+ # Redis (used for scenario + session caching)
11
+ REDIS_URL=redis://localhost:6379
12
+ CACHE_TTL_SECONDS=86400
13
+ SESSION_TTL_SECONDS=3600
14
+
15
+ # LLM for inference.py (OpenAI-compatible)
16
+ HF_TOKEN=
17
+ OPENAI_API_KEY= # alias for HF_TOKEN
18
+ MODEL_NAME=gpt-4o
19
+ API_BASE_URL=https://api.openai.com/v1
20
+
21
+ # Environment server
22
+ ENV_SERVER_URL=http://localhost:8000
23
+ HOST=0.0.0.0
24
+ PORT=8000
25
+ LOG_LEVEL=info
26
+
27
+ # Data collector
28
+ MAX_SOURCES_PER_CLAIM=8
29
+ REQUEST_TIMEOUT=10
30
+
31
+ # Config files (override if you move them)
32
+ RELIABILITY_CONFIG=domain_reliability.yaml
33
+ REWARD_CONFIG=reward_config.yaml
34
+ EXPLANATION_CONFIG=explanation_templates.yaml
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual environments
2
+ .venv/
3
+ venv/
4
+ env/
5
+ ENV/
6
+
7
+ # Python cache
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.pyo
11
+ *.pyd
12
+
13
+ # HuggingFace model cache
14
+ .cache/
15
+
16
+ # Environment variables
17
+ .env
18
+
19
+ # macOS
20
+ .DS_Store
21
+
22
+ # IDE
23
+ .idea/
24
+ .vscode/
25
+
26
+ # Distribution / packaging
27
+ *.egg-info/
28
+ dist/
29
+ build/
Dockerfile ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Dockerfile v1.2.0
3
+ # Multi-stage build β€” slim production image.
4
+ #
5
+ # Build: docker build -t info-integrity-env .
6
+ # Run: docker run --env-file .env -p 7860:7860 info-integrity-env
7
+ #
8
+ # Evaluation path (no external services needed):
9
+ # docker run --env-file .env info-integrity-env \
10
+ # python inference.py --benchmark --out /tmp/results.json
11
+ #
12
+ # Required .env keys for the API server:
13
+ # HF_TOKEN (for inference.py)
14
+ # MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
15
+ # API_BASE_URL=https://router.huggingface.co/v1
16
+ #
17
+ # Optional (only needed for live data collection via /collect):
18
+ # SERPAPI_KEY / NEWSAPI_KEY / BRAVE_SEARCH_KEY / GOOGLE_FACTCHECK_KEY
19
+ # REDIS_URL=redis://redis:6379
20
+ # =============================================================================
21
+
22
+ # ---- Stage 1: builder ----
23
+ FROM python:3.11-slim AS builder
24
+
25
+ WORKDIR /app
26
+
27
+ RUN apt-get update && apt-get install -y --no-install-recommends \
28
+ build-essential \
29
+ && rm -rf /var/lib/apt/lists/*
30
+
31
+ COPY requirements.txt .
32
+ RUN pip install --no-cache-dir --upgrade pip \
33
+ && pip install --no-cache-dir -r requirements.txt
34
+
35
+ # ---- Stage 2: runtime ----
36
+ FROM python:3.11-slim AS runtime
37
+
38
+ WORKDIR /app
39
+
40
+ # Copy installed packages from builder
41
+ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
42
+ COPY --from=builder /usr/local/bin /usr/local/bin
43
+
44
+ # Copy application source
45
+ COPY models.py ./models.py
46
+ COPY environment.py ./environment.py
47
+ COPY reward.py ./reward.py
48
+ COPY explainability.py ./explainability.py
49
+ COPY data_collector.py ./data_collector.py
50
+ COPY app.py ./app.py
51
+ COPY server.py ./server.py
52
+ COPY inference.py ./inference.py
53
+ COPY reward_config.yaml ./reward_config.yaml
54
+ COPY openenv.yaml ./openenv.yaml
55
+ COPY README.md ./README.md
56
+
57
+ # Copy optional config files if they exist
58
+ COPY domain_reliability.yaml* ./
59
+
60
+ # Copy tasks package (graders + registry + deterministic scenario datasets)
61
+ COPY tasks/ ./tasks/
62
+
63
+ # Non-root user for security
64
+ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
65
+ USER appuser
66
+
67
+ # Environment defaults (override via --env-file or -e flags)
68
+ ENV PYTHONUNBUFFERED=1 \
69
+ PYTHONDONTWRITEBYTECODE=1 \
70
+ HOST=0.0.0.0 \
71
+ PORT=7860 \
72
+ LOG_LEVEL=info \
73
+ REDIS_URL=redis://localhost:6379 \
74
+ SESSION_TTL_SECONDS=3600 \
75
+ CACHE_TTL_SECONDS=86400 \
76
+ MAX_SOURCES_PER_CLAIM=8 \
77
+ REQUEST_TIMEOUT=10 \
78
+ BENCHMARK_SEED=42 \
79
+ MAX_STEPS=20 \
80
+ GRADIO_ANALYTICS_ENABLED=false
81
+
82
+ EXPOSE 7860
83
+
84
+ # Use Python's built-in urllib β€” no curl dependency needed in runtime
85
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
86
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health', timeout=8)"
87
+
88
+ # Default: start the API server
89
+ # Override for benchmark: docker run ... python inference.py --benchmark
90
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: InformationIntegrityEnv
3
+ emoji: πŸ”
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 6.11.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ tags:
12
+ - openenv
13
+ - content-moderation
14
+ - rl-environment
15
+ - agent-benchmark
16
+ ---
17
+
18
+ # InformationIntegrityEnv
19
+
20
+ A **deterministic, benchmarkable OpenEnv environment** for evaluating LLM agents on reasoning-based content moderation.
21
+
22
+ Agents must verify claims, weigh conflicting evidence, and make explainable moderation decisions (`allow` / `flag` / `remove`). Three difficulty tiers β€” easy, medium, hard β€” provide 15 fully static scenarios that run completely offline.
23
+
24
+ ---
25
+
26
+ ## Architecture
27
+
28
+ ```
29
+ tasks/
30
+ easy/scenarios.json ← 5 static scenarios (clear-cut misinformation)
31
+ medium/scenarios.json ← 5 static scenarios (misleading / mixed evidence)
32
+ hard/scenarios.json ← 5 static scenarios (conflicting evidence, nuanced)
33
+ graders.py ← EasyGrader / MediumGrader / HardGrader
34
+ registry.py ← TASKS["easy"] / TASKS["medium"] / TASKS["hard"]
35
+ β”‚
36
+ β–Ό
37
+ InformationIntegrityEnv (environment.py)
38
+ reset(difficulty, scenario_id) β†’ Observation
39
+ step(action) β†’ (Observation, Reward, done, info)
40
+ state() β†’ full debug state
41
+ β”‚
42
+ β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”
43
+ β”‚ β”‚
44
+ inference.py server.py
45
+ (benchmark (FastAPI
46
+ agent loop) REST API)
47
+ ```
48
+
49
+ No external API calls occur during evaluation. `DataCollector` is only used to build/refresh datasets.
50
+
51
+ ---
52
+
53
+ ## Quick Start
54
+
55
+ ### 1. Install
56
+
57
+ ```bash
58
+ pip install -r requirements.txt
59
+ ```
60
+
61
+ ### 2. Set API keys (for the LLM agent β€” not needed for just the server)
62
+
63
+ ```bash
64
+ export HF_TOKEN=sk-... # or OPENAI_API_KEY
65
+ export MODEL_NAME=gpt-4o
66
+ export API_BASE_URL=https://api.openai.com/v1
67
+ ```
68
+
69
+ ### 3. Run the full benchmark
70
+
71
+ ```bash
72
+ python inference.py --benchmark --out results.json
73
+ ```
74
+
75
+ This loops over all 15 scenarios (5 per difficulty) and prints:
76
+
77
+ ```
78
+ [START] episode=E01 difficulty=easy claims=1 sources=5
79
+ [STEP] step=1 action=check_source reward=+0.200 total=+0.200 done=False ...
80
+ [STEP] step=2 action=verify_claim reward=+0.200 total=+0.400 done=False ...
81
+ [STEP] step=3 action=decide reward=+1.000 total=+1.400 done=True ...
82
+ [END] episode=E01 steps=3 decision=remove total_reward=+1.4000 grader_score=0.8750
83
+
84
+ ...
85
+
86
+ difficulty=easy avg_score=0.7800 avg_reward=1.2000 n=5
87
+ difficulty=medium avg_score=0.6300 avg_reward=0.9000 n=5
88
+ difficulty=hard avg_score=0.5200 avg_reward=0.6500 n=5
89
+ Difficulty tiers well separated: easy > medium > hard βœ“
90
+ ```
91
+
92
+ ### 4. Single episode (local, no server)
93
+
94
+ ```bash
95
+ # Random scenario from medium dataset
96
+ python inference.py --local --difficulty medium
97
+
98
+ # Specific scenario by index
99
+ python inference.py --local --difficulty hard --scenario-id 2
100
+
101
+ # Specific scenario by id
102
+ python inference.py --local --difficulty easy --scenario-id easy_001
103
+
104
+ # From a custom JSON file (DataCollector output)
105
+ python inference.py --local --scenario my_scenario.json
106
+ ```
107
+
108
+ ### 5. Start the API server
109
+
110
+ ```bash
111
+ python server.py
112
+ # or
113
+ uvicorn server:app --reload
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Action Space
119
+
120
+ | Action | Required fields | Description |
121
+ |--------|----------------|-------------|
122
+ | `verify_claim` | `claim_id` | Investigate a claim; returns true / false / partially_true / unknown |
123
+ | `check_source` | `source_id` | Read a source; returns title, text, stance, reliability |
124
+ | `decide` | `decision` | Final call: `allow` / `flag` / `remove` β€” ends episode |
125
+
126
+ All actions accept an optional `reasoning` string (logged, not graded).
127
+
128
+ **Minimum evidence required before deciding** (or premature penalty applies):
129
+ - β‰₯ 1 claim verified
130
+ - β‰₯ 2 sources checked
131
+
132
+ ---
133
+
134
+ ## Observation Space
135
+
136
+ ```json
137
+ {
138
+ "content": "The post text under review",
139
+ "claims": [
140
+ {"id": "claim_001", "text": "...", "verification_status": "unverified", "confidence": 0.0}
141
+ ],
142
+ "available_sources": [
143
+ {"id": "src_001", "title": "...", "text": "...", "url": "...",
144
+ "stance": "contradicting", "reliability": 0.97, "source_type": "official"}
145
+ ],
146
+ "interaction_history": [],
147
+ "status_flags": {
148
+ "decision_made": false,
149
+ "sufficient_evidence": false,
150
+ "premature_decision": false,
151
+ "redundant_actions": 0,
152
+ "max_steps_reached": false
153
+ },
154
+ "step_count": 0,
155
+ "task_difficulty": "medium",
156
+ "episode_id": "uuid"
157
+ }
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Reward Function
163
+
164
+ | Component | Value | Trigger |
165
+ |-----------|-------|---------|
166
+ | Verify claim | +0.20 | Per unique claim verified |
167
+ | Check source | +0.20 | Per unique source checked |
168
+ | Multi-source bonus | +0.20 | When β‰₯ 3 unique sources checked |
169
+ | Correct decision | +1.00 | Exact ground truth match |
170
+ | Partial decision | +0.50 | One tier off (flag↔remove, flag↔allow) |
171
+ | Premature decision | βˆ’0.50 | Deciding before sufficient evidence |
172
+ | Redundant action | βˆ’0.10 | Re-checking same claim/source |
173
+ | Inefficiency | βˆ’0.05 | Per step beyond efficient threshold |
174
+ | Over-aggression | βˆ’0.30 | `remove` when ground truth is `allow`/`flag` |
175
+
176
+ Total clipped to `[-2.0, +2.0]`. All values configurable in `reward_config.yaml`.
177
+
178
+ ---
179
+
180
+ ## Grader Scoring
181
+
182
+ Each episode is scored `[0.0, 1.0]` across four dimensions:
183
+
184
+ | Dimension | Easy | Medium | Hard |
185
+ |-----------|------|--------|------|
186
+ | Decision correctness | 50% | 35% | 30% |
187
+ | Evidence breadth | 25% | 35% | 30% |
188
+ | Reasoning depth | 15% | 20% | 30% |
189
+ | Efficiency | 10% | 10% | 10% |
190
+
191
+ ---
192
+
193
+ ## Task Datasets
194
+
195
+ ### Easy (5 scenarios)
196
+ Clear-cut false claims contradicted by multiple authoritative sources (WHO, CDC, NASA, FDA).
197
+ Ground truth: 3Γ— remove, 1Γ— flag, 1Γ— allow.
198
+
199
+ | ID | Topic | Ground Truth |
200
+ |----|-------|-------------|
201
+ | easy_001 | Bleach cures COVID | remove |
202
+ | easy_002 | NASA fabricated climate data | remove |
203
+ | easy_003 | mRNA vaccines alter DNA | remove |
204
+ | easy_004 | Sydney is Australia's capital | flag |
205
+ | easy_005 | Webb telescope early universe | allow |
206
+
207
+ ### Medium (5 scenarios)
208
+ Partially true or misleadingly framed content. Mixed evidence requires weighing.
209
+ Ground truth: 4Γ— flag, 1Γ— allow.
210
+
211
+ | ID | Topic | Ground Truth |
212
+ |----|-------|-------------|
213
+ | medium_001 | Sugar more addictive than cocaine | flag |
214
+ | medium_002 | Real unemployment is 20%+ | flag |
215
+ | medium_003 | AI will eliminate 85M jobs | flag |
216
+ | medium_004 | Pharma knew OxyContin was addictive | allow |
217
+ | medium_005 | Facebook hid teen mental health data | flag |
218
+
219
+ ### Hard (5 scenarios)
220
+ Conflicting credible evidence, genuinely contested claims, contested policy debates.
221
+ Ground truth: 5Γ— flag.
222
+
223
+ | ID | Topic | Ground Truth |
224
+ |----|-------|-------------|
225
+ | hard_001 | NATO provoked Ukraine invasion | flag |
226
+ | hard_002 | Lockdowns killed more than COVID | flag |
227
+ | hard_003 | SEC crackdown is banking conspiracy | flag |
228
+ | hard_004 | AI poses extinction-level risk | flag |
229
+ | hard_005 | British colonialism net beneficial | flag |
230
+
231
+ ---
232
+
233
+ ## API Reference
234
+
235
+ | Method | Endpoint | Description |
236
+ |--------|----------|-------------|
237
+ | GET | `/health` | Liveness probe |
238
+ | GET | `/tasks` | List all available scenario datasets |
239
+ | POST | `/reset` | Start a new episode |
240
+ | POST | `/step` | Submit one action |
241
+ | GET | `/state/{session_id}` | Full internal state (debug/audit) |
242
+ | GET | `/scenario/{session_id}` | Scenario metadata (no ground truth) |
243
+ | DELETE | `/session/{session_id}` | End session and free memory |
244
+ | POST | `/collect` | Build a live scenario via DataCollector (dataset creation only) |
245
+
246
+ ### Reset β€” evaluation path (no DataCollector needed)
247
+
248
+ ```bash
249
+ # Random scenario from easy dataset
250
+ curl -X POST http://localhost:8000/reset \
251
+ -H "Content-Type: application/json" \
252
+ -d '{"difficulty": "easy"}'
253
+
254
+ # Specific scenario by index
255
+ curl -X POST http://localhost:8000/reset \
256
+ -d '{"difficulty": "medium", "scenario_id": 2}'
257
+
258
+ # Specific scenario by id
259
+ curl -X POST http://localhost:8000/reset \
260
+ -d '{"difficulty": "hard", "scenario_id": "hard_001"}'
261
+ ```
262
+
263
+ ### Full episode example
264
+
265
+ ```bash
266
+ SESSION=$(curl -s -X POST http://localhost:8000/reset \
267
+ -H "Content-Type: application/json" \
268
+ -d '{"difficulty": "easy"}' | jq -r .session_id)
269
+
270
+ # Check a source
271
+ curl -X POST http://localhost:8000/step \
272
+ -d "{\"session_id\": \"$SESSION\", \"action\": {\"type\": \"check_source\", \"source_id\": \"src_001\"}}"
273
+
274
+ # Verify a claim
275
+ curl -X POST http://localhost:8000/step \
276
+ -d "{\"session_id\": \"$SESSION\", \"action\": {\"type\": \"verify_claim\", \"claim_id\": \"claim_001\"}}"
277
+
278
+ # Decide
279
+ curl -X POST http://localhost:8000/step \
280
+ -d "{\"session_id\": \"$SESSION\", \"action\": {\"type\": \"decide\", \"decision\": \"remove\"}}"
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Docker
286
+
287
+ ### Server (default)
288
+
289
+ ```bash
290
+ docker build -t info-integrity-env .
291
+ docker run --env-file .env -p 8000:8000 info-integrity-env
292
+ ```
293
+
294
+ ### Benchmark (offline β€” no external services needed)
295
+
296
+ ```bash
297
+ docker run --env-file .env info-integrity-env \
298
+ python inference.py --benchmark --out /tmp/results.json
299
+ ```
300
+
301
+ ### Full stack with Redis (for live data collection)
302
+
303
+ ```bash
304
+ docker compose up --build
305
+ ```
306
+
307
+ ---
308
+
309
+ ## Baseline Results
310
+
311
+ | Difficulty | Avg Grader Score | Avg Reward | Decision Accuracy |
312
+ |-----------|-----------------|------------|-----------------|
313
+ | Easy | ~0.78 | ~1.20 | ~85% |
314
+ | Medium | ~0.63 | ~0.90 | ~70% |
315
+ | Hard | ~0.52 | ~0.65 | ~55% |
316
+
317
+ *Results vary by LLM. Scores above from `gpt-4o` with temperature=0.*
318
+
319
+ ---
320
+
321
+ ## Extending the Environment
322
+
323
+ | File | What to change |
324
+ |------|---------------|
325
+ | `tasks/{difficulty}/scenarios.json` | Add or edit scenarios (run `openenv validate` after) |
326
+ | `reward_config.yaml` | Tune reward/penalty magnitudes |
327
+ | `tasks/graders.py` | Register custom graders: `GraderFactory.register("hard_v2", MyGrader)` |
328
+ | `data_collector.py` | Add new search backends via `SearchBackend` ABC |
329
+ | `domain_reliability.yaml` | Add trusted domains with custom reliability scores |
330
+
331
+ ---
332
+
333
+ ## Project Structure
334
+
335
+ ```
336
+ β”œβ”€β”€ models.py Pydantic models (Observation, Action, Reward, Scenario, ...)
337
+ β”œβ”€β”€ environment.py InformationIntegrityEnv (OpenEnv core)
338
+ β”œβ”€β”€ reward.py Configurable dense reward function
339
+ β”œβ”€β”€ explainability.py Structured reasoning + template-based explanation
340
+ β”œβ”€β”€ data_collector.py Multi-backend data collection (dataset creation only)
341
+ β”œβ”€β”€ inference.py Benchmark agent + single-episode runner
342
+ β”œβ”€β”€ server.py FastAPI server (HF Space / Docker)
343
+ β”œβ”€β”€ tasks/
344
+ β”‚ β”œβ”€β”€ __init__.py
345
+ β”‚ β”œβ”€β”€ graders.py Easy / Medium / Hard deterministic graders
346
+ β”‚ β”œβ”€β”€ registry.py TASKS registry β€” lazy-loads scenario datasets
347
+ β”‚ β”œβ”€β”€ easy/
348
+ β”‚ β”‚ └── scenarios.json 5 easy scenarios
349
+ β”‚ β”œβ”€β”€ medium/
350
+ β”‚ β”‚ └── scenarios.json 5 medium scenarios
351
+ β”‚ └── hard/
352
+ β”‚ └── scenarios.json 5 hard scenarios
353
+ β”œβ”€β”€ openenv.yaml OpenEnv v1.1.0 specification
354
+ β”œβ”€β”€ reward_config.yaml Reward magnitudes (runtime-configurable)
355
+ β”œβ”€β”€ domain_reliability.yaml Domain trust scores for DataCollector
356
+ β”œβ”€β”€ Dockerfile Production container
357
+ β”œβ”€β”€ docker-compose.yml Local dev stack (server + Redis)
358
+ └── requirements.txt
359
+ ```
app.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py
3
+ ======
4
+ HuggingFace Spaces entry point (Gradio SDK).
5
+
6
+ Mounts the FastAPI OpenEnv server and exposes a minimal Gradio UI
7
+ so the Space is recognised as a Gradio app while all OpenEnv HTTP
8
+ endpoints (/reset, /step, /state, /health) remain accessible.
9
+ """
10
+
11
+ import uvicorn
12
+ import gradio as gr
13
+ from server import app as fastapi_app
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Minimal Gradio demo (satisfies HF Spaces Gradio SDK requirement)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def run_health_check():
20
+ return "InformationIntegrityEnv is running. Use /reset, /step, /health endpoints."
21
+
22
+ demo = gr.Interface(
23
+ fn=run_health_check,
24
+ inputs=[],
25
+ outputs=gr.Textbox(label="Status"),
26
+ title="InformationIntegrityEnv",
27
+ description=(
28
+ "OpenEnv-compatible deterministic benchmark for reasoning-based content moderation.\n\n"
29
+ "**API Endpoints:**\n"
30
+ "- `POST /reset` β€” start episode (`{\"difficulty\": \"easy|medium|hard\"}`)\n"
31
+ "- `POST /step` β€” submit action\n"
32
+ "- `GET /health` β€” liveness check\n"
33
+ "- `GET /tasks` β€” list available scenarios"
34
+ ),
35
+ )
36
+
37
+ # Mount Gradio onto the FastAPI app so both are served on the same port
38
+ app = gr.mount_gradio_app(fastapi_app, demo, path="/ui")
39
+
40
+ if __name__ == "__main__":
41
+ uvicorn.run(app, host="0.0.0.0", port=7860)
data_collector.py ADDED
@@ -0,0 +1,1229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data_collector.py
3
+ =================
4
+ Production data-collection pipeline for InformationIntegrity OpenEnv.
5
+
6
+ Takes any claim string(s), fetches real evidence from multiple search/news
7
+ backends, scores source reliability, classifies stance, and caches results
8
+ in Redis. The output is a Scenario JSON ready for the environment.
9
+
10
+ Backends (all optional β€” falls back gracefully):
11
+ 1. SerpAPI SERPAPI_KEY
12
+ 2. NewsAPI NEWSAPI_KEY
13
+ 3. Brave Search BRAVE_SEARCH_KEY
14
+ 4. Google Fact Check GOOGLE_FACTCHECK_KEY
15
+ 5. DuckDuckGo HTML (no key, rate-limited fallback)
16
+
17
+ Environment variables:
18
+ SERPAPI_KEY
19
+ NEWSAPI_KEY
20
+ BRAVE_SEARCH_KEY
21
+ GOOGLE_FACTCHECK_KEY
22
+ REDIS_URL (default: redis://localhost:6379)
23
+ CACHE_TTL_SECONDS (default: 86400 β€” 24h)
24
+ RELIABILITY_CONFIG (default: domain_reliability.yaml)
25
+ MAX_SOURCES_PER_CLAIM (default: 8)
26
+ REQUEST_TIMEOUT (default: 10s)
27
+
28
+ Usage:
29
+ python data_collector.py --claims "Claim one" "Claim two" \
30
+ --content "The original post text" \
31
+ --difficulty medium \
32
+ --out scenario.json
33
+
34
+ from data_collector import DataCollector, CollectionRequest
35
+ collector = DataCollector()
36
+ scenario = collector.collect(CollectionRequest(
37
+ content="Post text",
38
+ claims=["Claim A"],
39
+ difficulty="medium",
40
+ ))
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import hashlib
46
+ import json
47
+ import logging
48
+ import os
49
+ import re
50
+ import time
51
+ import urllib.parse
52
+ import uuid
53
+ from abc import ABC, abstractmethod
54
+ from dataclasses import dataclass, field
55
+ from typing import Any, Dict, List, Optional
56
+ from urllib.parse import urlparse
57
+
58
+ import fnmatch
59
+ import requests
60
+ import yaml
61
+
62
+ # Load .env BEFORE any os.getenv() calls so API keys are available
63
+ try:
64
+ from dotenv import load_dotenv
65
+ load_dotenv()
66
+ except ImportError:
67
+ pass # python-dotenv not installed; rely on shell environment
68
+
69
+ try:
70
+ from retrieval import QueryRewriter as _QueryRewriter
71
+ _QUERY_REWRITER = _QueryRewriter()
72
+ except Exception:
73
+ _QUERY_REWRITER = None # type: ignore
74
+
75
+ # Import the enhanced stance classifier with fallback to original
76
+ try:
77
+ from enhanced_stance_classifier import EnhancedStanceClassifier as _StanceClassifier, build_enhanced_stance_classifier
78
+ _USE_ENHANCED_STANCE = True
79
+ logger_tmp = logging.getLogger("data_collector")
80
+ logger_tmp.info("Using enhanced stance classifier with improved context understanding")
81
+ except Exception as _e:
82
+ logger_tmp = logging.getLogger("data_collector")
83
+ logger_tmp.warning("Enhanced stance_classifier unavailable (%s) β€” falling back to original", _e)
84
+ try:
85
+ from stance_classifier import StanceClassifier as _StanceClassifier, build_stance_classifier
86
+ _USE_ENHANCED_STANCE = False
87
+ _USE_NEW_STANCE = True
88
+ except Exception as _e2:
89
+ logger_tmp.warning("Original stance_classifier also unavailable (%s) β€” using legacy keywords", _e2)
90
+ _USE_ENHANCED_STANCE = False
91
+ _USE_NEW_STANCE = False
92
+
93
+ try:
94
+ import redis
95
+ _REDIS_AVAILABLE = True
96
+ except ImportError:
97
+ _REDIS_AVAILABLE = False
98
+
99
+ logging.basicConfig(
100
+ level=logging.INFO,
101
+ format="%(asctime)s [%(levelname)s] %(name)s β€” %(message)s",
102
+ )
103
+ logger = logging.getLogger("data_collector")
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Config
108
+ # ---------------------------------------------------------------------------
109
+
110
+ RELIABILITY_CONFIG_PATH = os.getenv("RELIABILITY_CONFIG", "domain_reliability.yaml")
111
+ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
112
+ CACHE_TTL = int(os.getenv("CACHE_TTL_SECONDS", 86400))
113
+ MAX_SOURCES = int(os.getenv("MAX_SOURCES_PER_CLAIM", 8))
114
+ REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", 10))
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Domain Reliability Scorer
119
+ # ---------------------------------------------------------------------------
120
+
121
+ class DomainReliabilityScorer:
122
+ """
123
+ Loads domain_reliability.yaml and scores any URL.
124
+ Fully enterprise-configurable without touching Python code.
125
+ """
126
+
127
+ def __init__(self, config_path: str = RELIABILITY_CONFIG_PATH) -> None:
128
+ self._config = self._load(config_path)
129
+ self._exact: Dict[str, tuple[float, str]] = {} # domain β†’ (score, type)
130
+ self._patterns: List[tuple[str, float, str]] = [] # (glob, score, type)
131
+ self._build_index()
132
+
133
+ def _load(self, path: str) -> dict:
134
+ if not os.path.exists(path):
135
+ logger.warning("Reliability config not found at %s β€” using defaults", path)
136
+ return {}
137
+ with open(path, "r", encoding="utf-8") as fh:
138
+ return yaml.safe_load(fh) or {}
139
+
140
+ def _build_index(self) -> None:
141
+ skip = {"version", "defaults", "stance_thresholds", "evidence_thresholds"}
142
+ for section_name, section in self._config.items():
143
+ if section_name in skip or not isinstance(section, dict):
144
+ continue
145
+ score = float(section.get("score", 0.35))
146
+ stype = str(section.get("source_type", "unknown"))
147
+ for domain in section.get("domains", []):
148
+ self._exact[domain.lower().lstrip("www.")] = (score, stype)
149
+ for pat in section.get("patterns", []):
150
+ self._patterns.append((pat.lower(), score, stype))
151
+
152
+ def score(self, url: str) -> tuple[float, str]:
153
+ """Return (reliability_score, source_type) for a URL."""
154
+ try:
155
+ host = urlparse(url).hostname or ""
156
+ except Exception:
157
+ host = ""
158
+ host = host.lower().lstrip("www.")
159
+
160
+ # Exact match (including subdomains stripped to base)
161
+ for candidate in self._candidates(host):
162
+ if candidate in self._exact:
163
+ return self._exact[candidate]
164
+
165
+ # Glob patterns (e.g. *.blogspot.com)
166
+ for pat, score, stype in self._patterns:
167
+ if fnmatch.fnmatch(host, pat):
168
+ return score, stype
169
+
170
+ defaults = self._config.get("defaults", {})
171
+ return (
172
+ float(defaults.get("unknown_domain_score", 0.35)),
173
+ str(defaults.get("unknown_source_type", "unknown")),
174
+ )
175
+
176
+ @staticmethod
177
+ def _candidates(host: str) -> List[str]:
178
+ """Generate host + progressively stripped parent domains."""
179
+ parts = host.split(".")
180
+ candidates = []
181
+ for i in range(len(parts)):
182
+ candidates.append(".".join(parts[i:]))
183
+ return candidates
184
+
185
+ @property
186
+ def stance_config(self) -> dict:
187
+ return self._config.get("stance_thresholds", {})
188
+
189
+ @property
190
+ def evidence_thresholds(self) -> dict:
191
+ return self._config.get("evidence_thresholds", {})
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Stance Classifier
196
+ # ---------------------------------------------------------------------------
197
+
198
+ class StanceClassifier:
199
+ """
200
+ Heuristic stance classification using keyword lists from the YAML config.
201
+ No ML dependency β€” deterministic and inspectable.
202
+ """
203
+
204
+ def __init__(self, scorer: DomainReliabilityScorer) -> None:
205
+ cfg = scorer.stance_config
206
+ self._contra_kw: List[str] = [k.lower() for k in cfg.get("contradicting_keywords", [])]
207
+ self._supp_kw: List[str] = [k.lower() for k in cfg.get("supporting_keywords", [])]
208
+ self._contra_min: int = int(cfg.get("contradicting_keyword_min", 2))
209
+ self._supp_min: int = int(cfg.get("supporting_keyword_min", 2))
210
+
211
+ def classify(self, text: str, claim: str) -> str:
212
+ """Return 'supporting' | 'contradicting' | 'neutral'."""
213
+ lowered = text.lower()
214
+ contra_hits = sum(1 for kw in self._contra_kw if kw in lowered)
215
+ supp_hits = sum(1 for kw in self._supp_kw if kw in lowered)
216
+
217
+ if contra_hits >= self._contra_min and contra_hits > supp_hits:
218
+ return "contradicting"
219
+ if supp_hits >= self._supp_min and supp_hits >= contra_hits:
220
+ return "supporting"
221
+ return "neutral"
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Data models for collection pipeline
226
+ # ---------------------------------------------------------------------------
227
+
228
+ @dataclass
229
+ class RawResult:
230
+ title: str
231
+ snippet: str
232
+ url: str
233
+ source: str # which backend returned this
234
+
235
+
236
+ @dataclass
237
+ class CollectionRequest:
238
+ content: str
239
+ claims: List[str]
240
+ difficulty: str = "medium"
241
+ scenario_id: str = field(default_factory=lambda: str(uuid.uuid4()))
242
+ tags: List[str] = field(default_factory=list)
243
+ topic: str = ""
244
+ force_refresh: bool = False
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Search backend interface + implementations
249
+ # ---------------------------------------------------------------------------
250
+
251
+ class SearchBackend(ABC):
252
+ name: str = "base"
253
+
254
+ @abstractmethod
255
+ def search(self, query: str, num_results: int = 8) -> List[RawResult]:
256
+ ...
257
+
258
+ def available(self) -> bool:
259
+ return True
260
+
261
+
262
+ class SerpAPIBackend(SearchBackend):
263
+ name = "serpapi"
264
+
265
+ def __init__(self) -> None:
266
+ self._key = os.getenv("SERPAPI_KEY", "")
267
+
268
+ def available(self) -> bool:
269
+ return bool(self._key)
270
+
271
+ def search(self, query: str, num_results: int = 8) -> List[RawResult]:
272
+ # Detect query intent to choose the right SerpAPI parameters
273
+ is_factcheck = re.search(r'\bfact.?check\b|debunked|hoax|false|misleading', query, re.I)
274
+ is_news = re.search(r'\bofficial\b|\bgovernment\b|\bannouncement\b', query, re.I)
275
+
276
+ params: dict = {
277
+ "q": query,
278
+ "api_key": self._key,
279
+ "num": num_results,
280
+ "hl": "en",
281
+ "gl": "us", # default country; overridden below if entity found
282
+ "safe": "off",
283
+ }
284
+
285
+ # For very recent claims (date entities present) restrict to last month
286
+ if re.search(r'\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b|\b\d{4}\b',
287
+ query, re.I):
288
+ params["tbs"] = "qdr:m" # last month
289
+
290
+ # Fact-check / debunking queries β†’ force news tab
291
+ if is_factcheck or is_news:
292
+ params["tbm"] = "nws"
293
+
294
+ # Adjust country context from entity
295
+ country_gl = {
296
+ "india": "in", "pakistan": "pk", "uk": "uk", "australia": "au",
297
+ "canada": "ca", "germany": "de", "france": "fr", "japan": "jp",
298
+ "china": "cn", "brazil": "br", "nigeria": "ng",
299
+ }
300
+ low_q = query.lower()
301
+ for country, gl in country_gl.items():
302
+ if country in low_q:
303
+ params["gl"] = gl
304
+ break
305
+
306
+ resp = requests.get(
307
+ "https://serpapi.com/search",
308
+ params=params,
309
+ timeout=REQUEST_TIMEOUT,
310
+ )
311
+ resp.raise_for_status()
312
+ data = resp.json()
313
+ results: List[RawResult] = []
314
+
315
+ # Answer box β€” featured snippet is very high signal
316
+ if ab := data.get("answer_box"):
317
+ snippet = ab.get("answer") or ab.get("snippet") or ab.get("result", "")
318
+ link = ab.get("link", "")
319
+ if snippet and link:
320
+ results.append(RawResult(
321
+ title = ab.get("title", "Featured Answer"),
322
+ snippet = str(snippet)[:600],
323
+ url = link,
324
+ source = f"{self.name}:answer_box",
325
+ ))
326
+
327
+ # Top stories (news carousel)
328
+ for story in data.get("top_stories", [])[:4]:
329
+ link = story.get("link", "")
330
+ if link:
331
+ results.append(RawResult(
332
+ title = story.get("title", ""),
333
+ snippet = story.get("snippet", story.get("title", "")),
334
+ url = link,
335
+ source = f"{self.name}:news",
336
+ ))
337
+
338
+ # Organic results
339
+ for item in data.get("organic_results", [])[:num_results]:
340
+ link = item.get("link", "")
341
+ if link:
342
+ results.append(RawResult(
343
+ title = item.get("title", ""),
344
+ snippet = item.get("snippet", ""),
345
+ url = link,
346
+ source = self.name,
347
+ ))
348
+
349
+ return results
350
+
351
+
352
+ class NewsAPIBackend(SearchBackend):
353
+ name = "newsapi"
354
+
355
+ def __init__(self) -> None:
356
+ self._key = os.getenv("NEWSAPI_KEY", "")
357
+
358
+ def available(self) -> bool:
359
+ return bool(self._key)
360
+
361
+ def search(self, query: str, num_results: int = 8) -> List[RawResult]:
362
+ params = {
363
+ "q": query,
364
+ "apiKey": self._key,
365
+ "language": "en",
366
+ "sortBy": "relevancy",
367
+ "pageSize": num_results,
368
+ }
369
+ resp = requests.get(
370
+ "https://newsapi.org/v2/everything",
371
+ params=params,
372
+ timeout=REQUEST_TIMEOUT,
373
+ )
374
+ resp.raise_for_status()
375
+ data = resp.json()
376
+ results = []
377
+ for article in data.get("articles", [])[:num_results]:
378
+ content = article.get("content") or ""
379
+ # NewsAPI truncates content with "[+N chars]" β€” strip that artifact
380
+ content = re.sub(r"\s*\[\+\d+ chars\]$", "", content).strip()
381
+ snippet = article.get("description") or content or ""
382
+ results.append(RawResult(
383
+ title=article.get("title", ""),
384
+ snippet=snippet[:500],
385
+ url=article.get("url", ""),
386
+ source=self.name,
387
+ ))
388
+ return results
389
+
390
+
391
+ class BraveSearchBackend(SearchBackend):
392
+ name = "brave"
393
+
394
+ def __init__(self) -> None:
395
+ self._key = os.getenv("BRAVE_SEARCH_KEY", "")
396
+
397
+ def available(self) -> bool:
398
+ return bool(self._key)
399
+
400
+ def search(self, query: str, num_results: int = 8) -> List[RawResult]:
401
+ headers = {
402
+ "Accept": "application/json",
403
+ "Accept-Encoding": "gzip",
404
+ "X-Subscription-Token": self._key,
405
+ }
406
+ params = {"q": query, "count": num_results, "text_decorations": False}
407
+ resp = requests.get(
408
+ "https://api.search.brave.com/res/v1/web/search",
409
+ headers=headers,
410
+ params=params,
411
+ timeout=REQUEST_TIMEOUT,
412
+ )
413
+ resp.raise_for_status()
414
+ data = resp.json()
415
+ results = []
416
+ for item in data.get("web", {}).get("results", [])[:num_results]:
417
+ results.append(RawResult(
418
+ title=item.get("title", ""),
419
+ snippet=item.get("description", ""),
420
+ url=item.get("url", ""),
421
+ source=self.name,
422
+ ))
423
+ return results
424
+
425
+
426
+ class GoogleFactCheckBackend(SearchBackend):
427
+ """
428
+ Google Fact Check Tools API.
429
+ Returns structured fact-check claims with verdicts from IFCN signatories.
430
+ """
431
+ name = "google_factcheck"
432
+
433
+ def __init__(self) -> None:
434
+ self._key = os.getenv("GOOGLE_FACTCHECK_KEY", "")
435
+
436
+ def available(self) -> bool:
437
+ return bool(self._key)
438
+
439
+ def search(self, query: str, num_results: int = 8) -> List[RawResult]:
440
+ params = {
441
+ "query": query,
442
+ "key": self._key,
443
+ "languageCode": "en",
444
+ "pageSize": num_results,
445
+ }
446
+ resp = requests.get(
447
+ "https://factchecktools.googleapis.com/v1alpha1/claims:search",
448
+ params=params,
449
+ timeout=REQUEST_TIMEOUT,
450
+ )
451
+ resp.raise_for_status()
452
+ data = resp.json()
453
+ results = []
454
+ for item in data.get("claims", [])[:num_results]:
455
+ reviews = item.get("claimReview", [])
456
+ for review in reviews[:1]:
457
+ publisher = review.get("publisher", {}).get("name", "")
458
+ rating = review.get("textualRating", "")
459
+ title = f"{publisher}: {rating}"
460
+ snippet = (
461
+ f"Claim: {item.get('text', '')} | "
462
+ f"Rating: {rating} | "
463
+ f"Review: {review.get('title', '')}"
464
+ )
465
+ url = review.get("url", "")
466
+ results.append(RawResult(
467
+ title=title,
468
+ snippet=snippet,
469
+ url=url,
470
+ source=self.name,
471
+ ))
472
+ return results
473
+
474
+
475
+ class DuckDuckGoFallbackBackend(SearchBackend):
476
+ """
477
+ No-key fallback using DuckDuckGo.
478
+
479
+ Rate-limit strategy
480
+ -------------------
481
+ DDG aggressively rate-limits when the same IP fires multiple queries
482
+ in quick succession. To work around this:
483
+
484
+ 1. Only 1 query is ever sent per search() call (controlled by the
485
+ n_queries=1 limit set in _fetch_all_backends).
486
+ 2. Exponential back-off: 5s β†’ 15s β†’ 45s between retries.
487
+ 3. On the first RateLimitException, promote directly to the HTML
488
+ fallback (no waiting) β€” the HTML endpoint has a separate rate limit.
489
+ 4. A class-level cooldown flag prevents hammering DDG when it's known
490
+ to be rate-limiting; the flag resets after _COOLDOWN_SECS seconds.
491
+ """
492
+ name = "duckduckgo"
493
+ _MAX_RETRIES = 2
494
+ _BASE_DELAY = 5.0 # seconds β€” first retry wait
495
+ _BACKOFF_MULT = 3.0 # each retry waits BASE * MULT^attempt
496
+ _COOLDOWN_SECS = 60.0 # after a rate-limit, skip DDG for this many seconds
497
+
498
+ # Class-level cooldown state (shared across all calls in the process)
499
+ _cooldown_until: float = 0.0
500
+
501
+ def available(self) -> bool:
502
+ return True
503
+
504
+ def search(self, query: str, num_results: int = 8) -> List[RawResult]:
505
+ import time as _time
506
+
507
+ # Respect session-level cooldown
508
+ if _time.time() < DuckDuckGoFallbackBackend._cooldown_until:
509
+ remaining = DuckDuckGoFallbackBackend._cooldown_until - _time.time()
510
+ logger.debug("DDG in cooldown for %.0fs β€” skipping to HTML fallback", remaining)
511
+ return self._html_fallback(query, num_results)
512
+
513
+ for attempt in range(self._MAX_RETRIES):
514
+ try:
515
+ from duckduckgo_search import DDGS
516
+ from duckduckgo_search.exceptions import RatelimitException
517
+ results = []
518
+ with DDGS() as ddgs:
519
+ for r in ddgs.text(query, max_results=num_results):
520
+ results.append(RawResult(
521
+ title = r.get("title", ""),
522
+ snippet = r.get("body", ""),
523
+ url = r.get("href", ""),
524
+ source = self.name,
525
+ ))
526
+ if results:
527
+ return results
528
+ logger.warning("DuckDuckGo returned 0 results (attempt %d)", attempt + 1)
529
+ time.sleep(self._BASE_DELAY * (self._BACKOFF_MULT ** attempt))
530
+
531
+ except ImportError:
532
+ return self._html_fallback(query, num_results)
533
+
534
+ except Exception as exc:
535
+ exc_str = str(exc)
536
+ is_ratelimit = (
537
+ "202" in exc_str
538
+ or "Ratelimit" in exc_str
539
+ or "ratelimit" in exc_str.lower()
540
+ or "rate" in exc_str.lower()
541
+ )
542
+ if is_ratelimit:
543
+ # Set cooldown and immediately try HTML β€” no point waiting
544
+ DuckDuckGoFallbackBackend._cooldown_until = (
545
+ time.time() + self._COOLDOWN_SECS
546
+ )
547
+ logger.warning(
548
+ "DDG rate-limited β€” switching to HTML fallback "
549
+ "(cooldown %ds)", self._COOLDOWN_SECS
550
+ )
551
+ return self._html_fallback(query, num_results)
552
+
553
+ logger.warning("DuckDuckGo attempt %d failed: %s", attempt + 1, exc)
554
+ time.sleep(self._BASE_DELAY * (self._BACKOFF_MULT ** attempt))
555
+
556
+ # All DDGS attempts exhausted β€” try HTML
557
+ return self._html_fallback(query, num_results)
558
+
559
+ def _html_fallback(self, query: str, num_results: int) -> List[RawResult]:
560
+ """Scrape DuckDuckGo HTML endpoint β€” separate rate limit from the API."""
561
+ import random
562
+ _USER_AGENTS = [
563
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
564
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
565
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
566
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
567
+ ]
568
+ encoded = urllib.parse.quote_plus(query)
569
+ url = f"https://html.duckduckgo.com/html/?q={encoded}"
570
+ headers = {
571
+ "User-Agent": random.choice(_USER_AGENTS),
572
+ "Accept-Language": "en-US,en;q=0.9",
573
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
574
+ }
575
+ try:
576
+ resp = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
577
+ resp.raise_for_status()
578
+ clean_tag = re.compile(r"<[^>]+>")
579
+ titles = re.findall(r'class="result__title">.*?<a[^>]+>(.*?)</a>', resp.text, re.S)
580
+ snippets = re.findall(r'class="result__snippet">(.*?)</a>', resp.text, re.S)
581
+ urls_raw = re.findall(r'class="result__url">(.*?)</span>', resp.text, re.S)
582
+ results = []
583
+ for t, s, u in zip(titles, snippets, urls_raw):
584
+ u = u.strip()
585
+ results.append(RawResult(
586
+ title = clean_tag.sub("", t).strip()[:200],
587
+ snippet = clean_tag.sub("", s).strip()[:500],
588
+ url = ("https://" + u) if not u.startswith("http") else u,
589
+ source = self.name,
590
+ ))
591
+ if len(results) >= num_results:
592
+ break
593
+ return results
594
+ except Exception as exc:
595
+ logger.error("DuckDuckGo HTML fallback failed: %s", exc)
596
+ return []
597
+
598
+
599
+ class WikipediaBackend(SearchBackend):
600
+ """
601
+ Wikipedia REST API β€” zero rate-limits, no key, always returns results.
602
+ Used as the guaranteed last-resort fallback when all other backends fail.
603
+ """
604
+ name = "wikipedia"
605
+
606
+ def available(self) -> bool:
607
+ return True
608
+
609
+ def search(self, query: str, num_results: int = 5) -> List[RawResult]:
610
+ results = []
611
+ # 1. Search for matching article titles
612
+ try:
613
+ search_resp = requests.get(
614
+ "https://en.wikipedia.org/w/api.php",
615
+ params={
616
+ "action": "query", "list": "search",
617
+ "srsearch": query, "srlimit": num_results,
618
+ "format": "json", "utf8": 1,
619
+ },
620
+ timeout=REQUEST_TIMEOUT,
621
+ headers={"User-Agent": "InformationIntegrityEnv/1.0 (research)"},
622
+ )
623
+ search_resp.raise_for_status()
624
+ hits = search_resp.json().get("query", {}).get("search", [])
625
+ except Exception as exc:
626
+ logger.warning("Wikipedia search failed: %s", exc)
627
+ return []
628
+
629
+ # 2. Fetch intro paragraph for each hit
630
+ for hit in hits[:num_results]:
631
+ title = hit.get("title", "")
632
+ snippet = re.sub(r"<[^>]+>", "", hit.get("snippet", "")).strip()
633
+ try:
634
+ summ_resp = requests.get(
635
+ f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(title)}",
636
+ timeout=REQUEST_TIMEOUT,
637
+ headers={"User-Agent": "InformationIntegrityEnv/1.0 (research)"},
638
+ )
639
+ if summ_resp.ok:
640
+ data = summ_resp.json()
641
+ snippet = data.get("extract", snippet)[:600]
642
+ url = data.get("content_urls", {}).get("desktop", {}).get("page",
643
+ f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title)}")
644
+ else:
645
+ url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title)}"
646
+ except Exception:
647
+ url = f"https://en.wikipedia.org/wiki/{urllib.parse.quote(title)}"
648
+
649
+ results.append(RawResult(
650
+ title = f"Wikipedia: {title}",
651
+ snippet = snippet,
652
+ url = url,
653
+ source = self.name,
654
+ ))
655
+
656
+ return results
657
+
658
+
659
+ # ---------------------------------------------------------------------------
660
+ # Full Content Fetcher
661
+ # ---------------------------------------------------------------------------
662
+
663
+ class FullContentFetcher:
664
+ """
665
+ Fetches and extracts the main article body from a URL.
666
+ Uses newspaper3k when available (best extraction quality), then falls back
667
+ to a BeautifulSoup heuristic that strips boilerplate and joins <p> tags.
668
+ Only called for sources whose reliability score >= FULL_CONTENT_MIN_RELIABILITY.
669
+ Returns None on any failure so callers can fall back to the snippet.
670
+ """
671
+
672
+ MIN_RELIABILITY: float = float(os.getenv("FULL_CONTENT_MIN_RELIABILITY", "0.6"))
673
+ MAX_CHARS: int = int(os.getenv("FULL_CONTENT_MAX_CHARS", "4000"))
674
+ FETCH_TIMEOUT: int = int(os.getenv("FULL_CONTENT_TIMEOUT", "12"))
675
+
676
+ _USER_AGENTS = [
677
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
678
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
679
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
680
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
681
+ ]
682
+
683
+ # Simple cache to avoid re-fetching the same URL within a single run
684
+ _cache: Dict[str, Optional[str]] = {}
685
+
686
+ def fetch(self, url: str) -> Optional[str]:
687
+ """Return extracted article text (up to MAX_CHARS) or None on failure."""
688
+ if url in self._cache:
689
+ return self._cache[url]
690
+
691
+ text = self._fetch_newspaper(url) or self._fetch_bs4(url)
692
+ if text:
693
+ text = text[:self.MAX_CHARS]
694
+ self._cache[url] = text
695
+ return text
696
+
697
+ def _fetch_newspaper(self, url: str) -> Optional[str]:
698
+ """Extract article using newspaper3k (best quality)."""
699
+ try:
700
+ from newspaper import Article # type: ignore
701
+ article = Article(url, request_timeout=self.FETCH_TIMEOUT)
702
+ article.download()
703
+ article.parse()
704
+ body = (article.text or "").strip()
705
+ return body if len(body) > 100 else None
706
+ except Exception:
707
+ return None
708
+
709
+ def _fetch_bs4(self, url: str) -> Optional[str]:
710
+ """Extract main content using BeautifulSoup as fallback."""
711
+ import random
712
+ try:
713
+ from bs4 import BeautifulSoup # type: ignore
714
+ except ImportError:
715
+ return None
716
+
717
+ try:
718
+ headers = {
719
+ "User-Agent": random.choice(self._USER_AGENTS),
720
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
721
+ "Accept-Language": "en-US,en;q=0.9",
722
+ }
723
+ resp = requests.get(url, headers=headers, timeout=self.FETCH_TIMEOUT, allow_redirects=True)
724
+ if not resp.ok or "text/html" not in resp.headers.get("Content-Type", ""):
725
+ return None
726
+
727
+ soup = BeautifulSoup(resp.text, "html.parser")
728
+
729
+ # Remove boilerplate elements
730
+ for tag in soup(["script", "style", "nav", "header", "footer",
731
+ "aside", "form", "noscript", "iframe", "figure"]):
732
+ tag.decompose()
733
+
734
+ # Heuristic: find the container with the most <p> text
735
+ candidates = soup.find_all(["article", "main", "div", "section"])
736
+ best_block = None
737
+ best_len = 0
738
+ for block in candidates:
739
+ text = " ".join(p.get_text(" ", strip=True) for p in block.find_all("p"))
740
+ if len(text) > best_len:
741
+ best_len = len(text)
742
+ best_block = text
743
+
744
+ if best_block and best_len > 150:
745
+ # Normalise whitespace
746
+ return re.sub(r"\s{2,}", " ", best_block).strip()
747
+
748
+ # Last resort: just get all paragraph text
749
+ all_p = " ".join(p.get_text(" ", strip=True) for p in soup.find_all("p"))
750
+ cleaned = re.sub(r"\s{2,}", " ", all_p).strip()
751
+ return cleaned if len(cleaned) > 150 else None
752
+
753
+ except Exception as exc:
754
+ logger.debug("BS4 content fetch failed for %s: %s", url[:60], exc)
755
+ return None
756
+
757
+
758
+ # ---------------------------------------------------------------------------
759
+ # Redis Cache
760
+ # ---------------------------------------------------------------------------
761
+
762
+ class ScenarioCache:
763
+ """
764
+ Redis-backed cache for collected scenarios.
765
+ Key = sha256(claim_fingerprint).
766
+ Falls back to an in-process dict if Redis is unavailable.
767
+ """
768
+
769
+ def __init__(self, redis_url: str = REDIS_URL, ttl: int = CACHE_TTL) -> None:
770
+ self._ttl = ttl
771
+ self._mem: Dict[str, Any] = {}
772
+ self._redis: Optional[Any] = None
773
+ if _REDIS_AVAILABLE:
774
+ try:
775
+ self._redis = redis.from_url(redis_url, decode_responses=True)
776
+ self._redis.ping()
777
+ logger.info("Redis cache connected at %s", redis_url)
778
+ except Exception as exc:
779
+ logger.warning("Redis unavailable (%s) β€” falling back to in-process cache", exc)
780
+ self._redis = None
781
+
782
+ @staticmethod
783
+ def _key(request: CollectionRequest) -> str:
784
+ fingerprint = json.dumps({
785
+ "claims": sorted(request.claims),
786
+ "difficulty": request.difficulty,
787
+ }, sort_keys=True)
788
+ return "scenario:" + hashlib.sha256(fingerprint.encode()).hexdigest()
789
+
790
+ def get(self, request: CollectionRequest) -> Optional[dict]:
791
+ k = self._key(request)
792
+ if self._redis:
793
+ raw = self._redis.get(k)
794
+ if raw:
795
+ return json.loads(raw)
796
+ return self._mem.get(k)
797
+
798
+ def set(self, request: CollectionRequest, scenario: dict) -> None:
799
+ k = self._key(request)
800
+ if self._redis:
801
+ self._redis.setex(k, self._ttl, json.dumps(scenario))
802
+ self._mem[k] = scenario
803
+
804
+ def invalidate(self, request: CollectionRequest) -> None:
805
+ k = self._key(request)
806
+ if self._redis:
807
+ self._redis.delete(k)
808
+ self._mem.pop(k, None)
809
+
810
+
811
+ # ---------------------------------------------------------------------------
812
+ # Main DataCollector
813
+ # ---------------------------------------------------------------------------
814
+
815
+ class DataCollector:
816
+ """
817
+ Orchestrates multi-backend search, reliability scoring, stance
818
+ classification, deduplication, and caching into a Scenario dict.
819
+ """
820
+
821
+ def __init__(
822
+ self,
823
+ backends: Optional[List[SearchBackend]] = None,
824
+ cache: Optional[ScenarioCache] = None,
825
+ scorer: Optional[DomainReliabilityScorer] = None,
826
+ ) -> None:
827
+ self._scorer = scorer or DomainReliabilityScorer()
828
+ self._cache = cache or ScenarioCache()
829
+ self._backends = backends or self._default_backends()
830
+
831
+ # Use enhanced stance classifier with fallbacks
832
+ if _USE_ENHANCED_STANCE:
833
+ self._stancer = build_enhanced_stance_classifier(self._scorer)
834
+ elif _USE_NEW_STANCE:
835
+ self._stancer = build_stance_classifier(self._scorer)
836
+ else:
837
+ self._stancer = StanceClassifier(self._scorer) # type: ignore
838
+
839
+ @staticmethod
840
+ def _default_backends() -> List[SearchBackend]:
841
+ # Disable DuckDuckGo due to rate limiting and reliability issues
842
+ candidates = [
843
+ SerpAPIBackend(),
844
+ NewsAPIBackend(),
845
+ BraveSearchBackend(),
846
+ GoogleFactCheckBackend(),
847
+ # DuckDuckGoFallbackBackend(), # Disabled - causes rate limiting issues
848
+ WikipediaBackend(), # guaranteed fallback β€” no rate-limits
849
+ ]
850
+ available = [b for b in candidates if b.available()]
851
+ if not available:
852
+ logger.warning("No search backends available β€” results will be empty")
853
+ logger.info(
854
+ "Active search backends: %s",
855
+ [b.name for b in available],
856
+ )
857
+ return available
858
+
859
+ # ------------------------------------------------------------------
860
+ # Public API
861
+ # ------------------------------------------------------------------
862
+
863
+ def collect(self, request: CollectionRequest) -> dict:
864
+ """
865
+ Main entry point. Returns a Scenario-compatible dict.
866
+ Checks Redis cache first; fetches from APIs on cache miss.
867
+ """
868
+ if not request.force_refresh:
869
+ cached = self._cache.get(request)
870
+ if cached:
871
+ logger.info("Cache hit for scenario %s", request.scenario_id)
872
+ return cached
873
+
874
+ scenario = self._build_scenario(request)
875
+ self._cache.set(request, scenario)
876
+ logger.info(
877
+ "Collected scenario %s β€” %d claims, %d sources",
878
+ scenario["id"],
879
+ len(scenario["claims"]),
880
+ len(scenario["sources"]),
881
+ )
882
+ return scenario
883
+
884
+ def collect_batch(self, requests: List[CollectionRequest]) -> List[dict]:
885
+ return [self.collect(r) for r in requests]
886
+
887
+ # ------------------------------------------------------------------
888
+ # Internal pipeline
889
+ # ------------------------------------------------------------------
890
+
891
+ def _build_scenario(self, req: CollectionRequest) -> dict:
892
+ all_sources = []
893
+ seen_urls: set[str] = set()
894
+ fetcher = FullContentFetcher()
895
+
896
+ for claim in req.claims:
897
+ raw_results = self._fetch_all_backends(claim)
898
+ for raw in raw_results:
899
+ norm_url = self._normalise_url(raw.url)
900
+ if not norm_url or norm_url in seen_urls:
901
+ continue
902
+ seen_urls.add(norm_url)
903
+
904
+ score, stype = self._scorer.score(norm_url)
905
+
906
+ # For high-reliability sources, fetch the full article body so
907
+ # stance classification has rich context rather than a 500-char snippet.
908
+ full_text: Optional[str] = None
909
+ if score >= FullContentFetcher.MIN_RELIABILITY:
910
+ full_text = fetcher.fetch(norm_url)
911
+ if full_text:
912
+ logger.info(
913
+ "Full content fetched for %s (%d chars, rel=%.2f)",
914
+ norm_url[:70], len(full_text), score,
915
+ )
916
+
917
+ # The text used for classification is full article when available,
918
+ # otherwise fall back to the snippet returned by the backend.
919
+ text_for_classification = full_text or raw.snippet
920
+
921
+ # Use enhanced classifier when available (returns detailed tuple),
922
+ # fall back to legacy interface (returns string only)
923
+ try:
924
+ if (_USE_ENHANCED_STANCE or _USE_NEW_STANCE) and hasattr(self._stancer, "classify_detailed"):
925
+ stance, _conf, _layer = self._stancer.classify_detailed(
926
+ title=raw.title,
927
+ text=text_for_classification,
928
+ claim=claim,
929
+ source_reliability=score,
930
+ )
931
+ logger.debug(
932
+ "Stance '%s' via %s (conf=%.2f): %s",
933
+ stance, _layer, _conf, raw.title[:60],
934
+ )
935
+ else:
936
+ stance = self._stancer.classify(text_for_classification + " " + raw.title, claim)
937
+ logger.debug("Stance '%s' via legacy: %s", stance, raw.title[:60])
938
+ except Exception as stance_exc:
939
+ logger.warning(
940
+ "Stance classification failed for '%s': %s - defaulting to neutral",
941
+ raw.title[:60], stance_exc
942
+ )
943
+ stance = "neutral"
944
+ src_id = f"src_{len(all_sources) + 1:03d}"
945
+
946
+ all_sources.append({
947
+ "id": src_id,
948
+ "title": raw.title[:200],
949
+ "text": (full_text or raw.snippet)[:800],
950
+ "url": norm_url,
951
+ "stance": stance,
952
+ "reliability": round(score, 3),
953
+ "source_type": stype,
954
+ "verification_weight": round(score, 3),
955
+ "fetched_by": raw.source,
956
+ "has_full_content": full_text is not None,
957
+ })
958
+
959
+ if len(all_sources) >= MAX_SOURCES * len(req.claims):
960
+ break
961
+
962
+ claims_payload = [
963
+ {
964
+ "id": f"claim_{i+1:03d}",
965
+ "text": c,
966
+ "ground_truth_status": self._infer_ground_truth_status(c, all_sources),
967
+ "ground_truth_confidence": self._infer_confidence(all_sources),
968
+ }
969
+ for i, c in enumerate(req.claims)
970
+ ]
971
+
972
+ ground_truth = self._infer_decision(claims_payload, all_sources)
973
+
974
+ return {
975
+ "id": req.scenario_id,
976
+ "difficulty": req.difficulty,
977
+ "topic": req.topic or self._guess_topic(req.content),
978
+ "tags": req.tags,
979
+ "content": req.content,
980
+ "claims": claims_payload,
981
+ "sources": all_sources,
982
+ "ground_truth": ground_truth,
983
+ "explanation_hint": self._build_explanation_hint(
984
+ req.claims, all_sources, ground_truth
985
+ ),
986
+ "_meta": {
987
+ "collected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
988
+ "backends_used": list({s["fetched_by"] for s in all_sources}),
989
+ "total_sources": len(all_sources),
990
+ },
991
+ }
992
+
993
+ def _fetch_all_backends(self, query: str) -> List[RawResult]:
994
+ """Try backends in priority order; aggregate unique results."""
995
+ results: List[RawResult] = []
996
+ seen_urls: set[str] = set()
997
+
998
+ # Use QueryRewriter for semantically rich query variants
999
+ if _QUERY_REWRITER is not None:
1000
+ enriched_queries = _QUERY_REWRITER.expand(query)
1001
+ else:
1002
+ keywords = query.strip().rstrip("?.")
1003
+ enriched_queries = [
1004
+ keywords,
1005
+ f'fact check "{keywords}"',
1006
+ f'"{keywords}" evidence',
1007
+ f'is it true that {keywords}',
1008
+ ]
1009
+
1010
+ for backend in self._backends:
1011
+ # SerpAPI gets all query variants (primary backend, paid, high rate-limit)
1012
+ # Other backends get 2 queries for better coverage
1013
+ if backend.name == "serpapi":
1014
+ n_queries = len(enriched_queries)
1015
+ else:
1016
+ n_queries = 2
1017
+ try:
1018
+ for q in enriched_queries[:n_queries]:
1019
+ for r in backend.search(q, num_results=MAX_SOURCES):
1020
+ if r.url and r.url not in seen_urls and (r.snippet or r.title):
1021
+ seen_urls.add(r.url)
1022
+ results.append(r)
1023
+ except Exception as exc:
1024
+ logger.warning("Backend %s failed for query '%s': %s", backend.name, query, exc)
1025
+
1026
+ # ── Keyword gate: drop results with <2 overlapping claim keywords ──────
1027
+ gated = [r for r in results if self._keyword_overlap(query, r) >= self._min_overlap(query)]
1028
+ # Safety net: if gate drops everything, keep original results (no empty pool)
1029
+ if not gated and results:
1030
+ logger.debug("Keyword gate dropped all results for '%s…' β€” keeping unfiltered", query[:60])
1031
+ return results[:MAX_SOURCES * 3]
1032
+
1033
+ return gated[:MAX_SOURCES * 3]
1034
+
1035
+ @staticmethod
1036
+ def _keyword_overlap(claim: str, raw: RawResult) -> int:
1037
+ """Count shared non-trivial keywords between the claim and a result."""
1038
+ _stop = {
1039
+ "the","a","an","is","are","was","were","in","on","at","to","from",
1040
+ "of","and","or","but","that","this","it","be","will","has","have",
1041
+ "its","by","for","not","with","also","into","than","more","about",
1042
+ }
1043
+ claim_kws = {w for w in re.findall(r'\b[a-z]{3,}\b', claim.lower()) if w not in _stop}
1044
+ result_kws = {w for w in re.findall(r'\b[a-z]{3,}\b',
1045
+ (raw.title + " " + raw.snippet).lower()) if w not in _stop}
1046
+ return len(claim_kws & result_kws)
1047
+
1048
+ @staticmethod
1049
+ def _min_overlap(claim: str) -> int:
1050
+ """
1051
+ Adaptive minimum overlap threshold based on claim length.
1052
+ Short claims (≀4 meaningful words) β†’ require 1 match.
1053
+ Longer claims β†’ require 2 matches (avoids generic Wikipedia hits).
1054
+ """
1055
+ _stop = {"the","a","an","is","are","was","were","in","on","at","to",
1056
+ "from","of","and","or","but","that","this","it","be","will"}
1057
+ n_kws = len({w for w in re.findall(r'\b[a-z]{3,}\b', claim.lower()) if w not in _stop})
1058
+ return 1 if n_kws <= 4 else 2
1059
+
1060
+ @staticmethod
1061
+ def _normalise_url(url: str) -> str:
1062
+ url = url.strip()
1063
+ if not url.startswith(("http://", "https://")):
1064
+ url = "https://" + url
1065
+ try:
1066
+ parsed = urlparse(url)
1067
+ return parsed.geturl()
1068
+ except Exception:
1069
+ return url
1070
+
1071
+ def _infer_ground_truth_status(self, claim: str, sources: List[dict]) -> str:
1072
+ """
1073
+ Heuristic: look at stance distribution weighted by reliability.
1074
+ Returns 'true' | 'false' | 'partially_true' | 'unknown'
1075
+ """
1076
+ contra_score = sum(
1077
+ s["reliability"] for s in sources if s["stance"] == "contradicting"
1078
+ )
1079
+ support_score = sum(
1080
+ s["reliability"] for s in sources if s["stance"] == "supporting"
1081
+ )
1082
+ total = contra_score + support_score
1083
+ if total == 0:
1084
+ return "unknown"
1085
+ ratio = support_score / total
1086
+ if ratio < 0.2:
1087
+ return "false"
1088
+ if ratio > 0.7:
1089
+ return "true"
1090
+ return "partially_true"
1091
+
1092
+ def _infer_confidence(self, sources: List[dict]) -> float:
1093
+ if not sources:
1094
+ return 0.0
1095
+ max_rel = max(s["reliability"] for s in sources)
1096
+ return round(min(max_rel, 1.0), 3)
1097
+
1098
+ def _infer_decision(self, claims: List[dict], sources: List[dict]) -> str:
1099
+ """
1100
+ Derive a moderation recommendation from weighted evidence.
1101
+ remove β†’ weighted false evidence dominant, high reliability
1102
+ flag β†’ mixed or uncertain evidence
1103
+ allow β†’ predominantly true, high-reliability support
1104
+ """
1105
+ false_weight = sum(
1106
+ s["reliability"]
1107
+ for s in sources
1108
+ if s["stance"] == "contradicting"
1109
+ )
1110
+ support_weight = sum(
1111
+ s["reliability"]
1112
+ for s in sources
1113
+ if s["stance"] == "supporting"
1114
+ )
1115
+ neutral_weight = sum(
1116
+ s["reliability"]
1117
+ for s in sources
1118
+ if s["stance"] == "neutral"
1119
+ )
1120
+ total = false_weight + support_weight + neutral_weight or 1.0
1121
+
1122
+ false_pct = false_weight / total
1123
+ support_pct = support_weight / total
1124
+
1125
+ # Strong contradicting signal β†’ remove
1126
+ if false_pct > 0.55 and false_weight > 1.5:
1127
+ return "remove"
1128
+ # Weak or mixed signal β†’ flag
1129
+ if false_pct > 0.30 or (abs(false_pct - support_pct) < 0.20):
1130
+ return "flag"
1131
+ # Predominantly supported and reliable β†’ allow
1132
+ if support_pct > 0.55 and support_weight > 1.0:
1133
+ return "allow"
1134
+ return "flag"
1135
+
1136
+ @staticmethod
1137
+ def _guess_topic(content: str) -> str:
1138
+ TOPICS = [
1139
+ ("health", ["vaccine", "covid", "cancer", "virus", "drug", "health", "disease"]),
1140
+ ("politics", ["election", "president", "government", "vote", "congress", "parliament"]),
1141
+ ("technology", ["5g", "ai", "chip", "microchip", "algorithm", "robot", "data"]),
1142
+ ("climate", ["climate", "warming", "carbon", "emission", "fossil", "renewable"]),
1143
+ ("economics", ["inflation", "gdp", "unemployment", "economy", "recession", "market"]),
1144
+ ("science", ["study", "research", "scientist", "experiment", "proof", "evidence"]),
1145
+ ]
1146
+ low = content.lower()
1147
+ for topic, keywords in TOPICS:
1148
+ if any(kw in low for kw in keywords):
1149
+ return topic
1150
+ return "general"
1151
+
1152
+ @staticmethod
1153
+ def _build_explanation_hint(claims: List[str], sources: List[dict], decision: str) -> str:
1154
+ contra = [s for s in sources if s["stance"] == "contradicting"]
1155
+ supp = [s for s in sources if s["stance"] == "supporting"]
1156
+ lines = [
1157
+ f"Decision: {decision}.",
1158
+ f"Claims examined: {len(claims)}.",
1159
+ f"Contradicting sources: {len(contra)}, Supporting sources: {len(supp)}.",
1160
+ ]
1161
+ if contra:
1162
+ top = max(contra, key=lambda s: s["reliability"])
1163
+ lines.append(f"Strongest contradicting: {top['title'][:80]} (reliability {top['reliability']}).")
1164
+ if supp:
1165
+ top = max(supp, key=lambda s: s["reliability"])
1166
+ lines.append(f"Strongest supporting: {top['title'][:80]} (reliability {top['reliability']}).")
1167
+ return " ".join(lines)
1168
+
1169
+
1170
+ # ---------------------------------------------------------------------------
1171
+ # CLI
1172
+ # ---------------------------------------------------------------------------
1173
+
1174
+ def _cli() -> None:
1175
+ import argparse
1176
+
1177
+ parser = argparse.ArgumentParser(
1178
+ description="Collect real-world evidence for a set of claims."
1179
+ )
1180
+ parser.add_argument(
1181
+ "--claims", nargs="+", required=True,
1182
+ help="One or more claim strings to investigate"
1183
+ )
1184
+ parser.add_argument(
1185
+ "--content", default="",
1186
+ help="The original piece of content containing the claims"
1187
+ )
1188
+ parser.add_argument(
1189
+ "--difficulty", default="medium",
1190
+ choices=["easy", "medium", "hard"],
1191
+ )
1192
+ parser.add_argument(
1193
+ "--topic", default="",
1194
+ help="Optional topic label"
1195
+ )
1196
+ parser.add_argument(
1197
+ "--tags", nargs="*", default=[],
1198
+ help="Optional tags"
1199
+ )
1200
+ parser.add_argument(
1201
+ "--out", default="scenario.json",
1202
+ help="Output file path"
1203
+ )
1204
+ parser.add_argument(
1205
+ "--force-refresh", action="store_true",
1206
+ help="Ignore Redis cache and re-fetch"
1207
+ )
1208
+ args = parser.parse_args()
1209
+
1210
+ collector = DataCollector()
1211
+ request = CollectionRequest(
1212
+ content=args.content or " ".join(args.claims),
1213
+ claims=args.claims,
1214
+ difficulty=args.difficulty,
1215
+ topic=args.topic,
1216
+ tags=args.tags,
1217
+ force_refresh=args.force_refresh,
1218
+ )
1219
+ scenario = collector.collect(request)
1220
+
1221
+ with open(args.out, "w", encoding="utf-8") as fh:
1222
+ json.dump(scenario, fh, indent=2, ensure_ascii=False)
1223
+
1224
+ logger.info("Scenario written to %s", args.out)
1225
+ print(json.dumps(scenario, indent=2, ensure_ascii=False))
1226
+
1227
+
1228
+ if __name__ == "__main__":
1229
+ _cli()
decision_engine.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decision_engine.py
3
+ ==================
4
+ High-level moderation decision engine.
5
+
6
+ Takes a summarised claim_result dict (produced after stance classification and
7
+ source retrieval) and returns a final moderation decision with recalibrated
8
+ confidence and a human-readable reason.
9
+
10
+ This layer sits *above* reasoning.py:
11
+ DataCollector β†’ StanceClassifier β†’ reasoning.py β†’ decision_engine.py β†’ UI
12
+
13
+ Why a separate layer?
14
+ reasoning.py works on raw ScoredSource objects (per-source contributions).
15
+ This module works on already-aggregated claim summaries, which is the shape
16
+ that the UI, API, and tests produce. The two layers complement each other β€”
17
+ reasoning.py computes the evidence score; this module applies the final
18
+ decision policy (thresholds, pattern boosts, confidence blending).
19
+
20
+ Usage
21
+ -----
22
+ from decision_engine import make_moderation_decision
23
+
24
+ result = make_moderation_decision({
25
+ "claim": "Humans only use 10% of their brain",
26
+ "status": "FALSE",
27
+ "confidence": 0.35,
28
+ "supporting_sources": 0,
29
+ "contradicting_sources": 3,
30
+ "neutral_sources": 1,
31
+ "avg_source_reliability": 0.72,
32
+ })
33
+ # β†’ {"decision": "AUTO_REMOVE", "final_confidence": 0.88, "reason": "..."}
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from dataclasses import dataclass, field
40
+ from typing import Optional
41
+
42
+
43
+ # ─────────────────────────────────────────────────────────────────────────────
44
+ # Configurable thresholds
45
+ # All values can be overridden per-call via the `config` parameter.
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ @dataclass
49
+ class DecisionConfig:
50
+ # ── AUTO_REMOVE thresholds ────────────────────────────────────────────────
51
+ # Minimum contradicting sources required for AUTO_REMOVE when status=FALSE
52
+ auto_remove_min_contra: int = 2
53
+ # OR: single-source override when that one source is very reliable
54
+ auto_remove_single_src_rel: float = 0.85
55
+ # Settled-science / known-misinformation pattern always triggers AUTO_REMOVE
56
+ # with just 1 contradicting source
57
+ auto_remove_pattern_min_contra: int = 1
58
+
59
+ # ── ALLOW thresholds ──────────────────────────────────────────────────────
60
+ allow_min_support: int = 2 # min supporting sources for ALLOW
61
+ allow_single_src_rel: float = 0.90 # single very-reliable source is enough
62
+
63
+ # ── Confidence blend weights (must sum to 1.0) ────────────────────────────
64
+ weight_stance: float = 0.50 # from status + incoming confidence
65
+ weight_evidence: float = 0.30 # from source counts
66
+ weight_reliability: float = 0.20 # from avg_source_reliability
67
+
68
+ # ── Evidence strength formula ─────────────────────────────────────────────
69
+ # evidence_strength = contra / (contra + support + 1) for FALSE claims
70
+ # evidence_strength = support / (support + contra + 1) for TRUE claims
71
+ # The +1 in denominator prevents division-by-zero and dampens single-source certainty.
72
+
73
+ # ── Pattern boost ─────────────────────────────────────────────────────────
74
+ pattern_boost: float = 0.15 # added to final_confidence when a known pattern matches
75
+
76
+ # ── Confidence floor/ceiling ──────────────────────────────────────────────
77
+ min_confidence: float = 0.10
78
+ max_confidence: float = 0.99
79
+
80
+
81
+ # Default singleton β€” callers can pass a custom config to override
82
+ _DEFAULT_CONFIG = DecisionConfig()
83
+
84
+
85
+ # ─────────────────────────────────────────────────────────────────────────────
86
+ # Known misinformation patterns
87
+ # ─────────────────────────────────────────────────────────────────────────────
88
+ # Each entry: (label, regex)
89
+ # When a claim text matches, the pattern_boost is applied AND the
90
+ # auto_remove threshold is lowered to auto_remove_pattern_min_contra.
91
+
92
+ _KNOWN_MISINFO: list[tuple[str, re.Pattern]] = [
93
+ # Neuroscience myth
94
+ ("10pct_brain", re.compile(
95
+ r"\b(10\s*%|ten\s+percent).{0,30}brain\b|\bbrain.{0,30}(10\s*%|ten\s+percent)\b",
96
+ re.IGNORECASE,
97
+ )),
98
+ # Flat earth
99
+ ("flat_earth", re.compile(
100
+ r"\b(earth\s+is\s+flat|flat\s+earth|earth\s+isn.t?\s+(round|spherical|globe))\b",
101
+ re.IGNORECASE,
102
+ )),
103
+ # Bleach / MMS as cure
104
+ ("bleach_cure", re.compile(
105
+ r"\b(drink(ing)?\s+bleach|bleach\s+cure[sd]?|mms\s+cure[sd]?|"
106
+ r"chlorine\s+dioxide\s+cure|inject(ing)?\s+disinfectant)\b",
107
+ re.IGNORECASE,
108
+ )),
109
+ # Vaccine-autism link
110
+ ("vaccines_autism", re.compile(
111
+ r"\bvaccines?\s+(cause[sd]?|linked?\s+to|lead[s]?\s+to)\s+autism\b",
112
+ re.IGNORECASE,
113
+ )),
114
+ # 5G / COVID
115
+ ("5g_covid", re.compile(
116
+ r"\b5g\s+(causes?|spreads?|created?|transmits?)\s+(covid|coronavirus|virus)\b",
117
+ re.IGNORECASE,
118
+ )),
119
+ # Climate hoax
120
+ ("climate_hoax", re.compile(
121
+ r"\bclimate\s+change\s+is\s+(a\s+)?(hoax|fake|not\s+real|fabricated)\b",
122
+ re.IGNORECASE,
123
+ )),
124
+ # Moon landing
125
+ ("moon_landing", re.compile(
126
+ r"\b(moon\s+landing\s+(was\s+)?(fake|faked|hoax|staged)|never\s+landed\s+on.{0,10}moon)\b",
127
+ re.IGNORECASE,
128
+ )),
129
+ # HIV denial
130
+ ("hiv_denial", re.compile(
131
+ r"\b(hiv\s+(doesn.t?\s+cause|doesn.t?\s+exist|is\s+fake)|aids\s+is\s+(a\s+)?(hoax|fake))\b",
132
+ re.IGNORECASE,
133
+ )),
134
+ # Evolution denial
135
+ ("evolution_denial", re.compile(
136
+ r"\b(evolution\s+is\s+(false|fake|a\s+lie|wrong|disproven)|humans?\s+didn.t?\s+evolve)\b",
137
+ re.IGNORECASE,
138
+ )),
139
+ ]
140
+
141
+
142
+ def _match_known_misinfo(claim: str) -> Optional[str]:
143
+ """
144
+ Returns the pattern label if the claim matches a known misinformation
145
+ pattern, otherwise None.
146
+ """
147
+ for label, pattern in _KNOWN_MISINFO:
148
+ if pattern.search(claim):
149
+ return label
150
+ return None
151
+
152
+
153
+ # ─────────────────────────────────────────────────────────────────────────────
154
+ # Confidence calculation
155
+ # ─────────────────────────────────────────────────────────────────────────────
156
+
157
+ def _compute_confidence(
158
+ status: str,
159
+ incoming_confidence: float,
160
+ supporting: int,
161
+ contradicting: int,
162
+ avg_reliability: float,
163
+ pattern_label: Optional[str],
164
+ cfg: DecisionConfig,
165
+ ) -> float:
166
+ """
167
+ Blend three independent signals into a single calibrated confidence score.
168
+
169
+ Component 1 β€” Stance confidence (weight=0.50)
170
+ Directly from the upstream classifier. Clamped to [0, 1].
171
+
172
+ Component 2 β€” Evidence strength (weight=0.30)
173
+ For FALSE claims: contra / (contra + support + 1)
174
+ For TRUE claims: support / (support + contra + 1)
175
+ For UNKNOWN: 0.0 (no directional evidence)
176
+ The +1 dampens over-confidence from a single source.
177
+
178
+ Component 3 β€” Source reliability (weight=0.20)
179
+ avg_source_reliability passed through directly.
180
+
181
+ Pattern boost (additive, after blend)
182
+ +cfg.pattern_boost when a known misinformation pattern is matched.
183
+ """
184
+ # Component 1
185
+ stance_conf = max(0.0, min(1.0, incoming_confidence))
186
+
187
+ # Component 2
188
+ total_directional = supporting + contradicting
189
+ if status == "FALSE" and contradicting > 0:
190
+ evidence_strength = contradicting / (total_directional + 1)
191
+ elif status == "TRUE" and supporting > 0:
192
+ evidence_strength = supporting / (total_directional + 1)
193
+ else:
194
+ evidence_strength = 0.0
195
+
196
+ # Component 3
197
+ reliability = max(0.0, min(1.0, avg_reliability))
198
+
199
+ # Weighted blend
200
+ blended = (
201
+ cfg.weight_stance * stance_conf +
202
+ cfg.weight_evidence * evidence_strength +
203
+ cfg.weight_reliability * reliability
204
+ )
205
+
206
+ # Pattern boost
207
+ if pattern_label is not None:
208
+ blended += cfg.pattern_boost
209
+
210
+ return round(max(cfg.min_confidence, min(cfg.max_confidence, blended)), 4)
211
+
212
+
213
+ # ─────────────────────────────────────────────────────────────────────────────
214
+ # Core decision logic
215
+ # ─────────────────────────────────────────────────────────────────────────────
216
+
217
+ def _decide(
218
+ status: str,
219
+ contradicting: int,
220
+ supporting: int,
221
+ avg_reliability: float,
222
+ pattern_label: Optional[str],
223
+ cfg: DecisionConfig,
224
+ ) -> tuple[str, str]:
225
+ """
226
+ Returns (decision, reason_fragment).
227
+
228
+ Decision priority:
229
+ 1. AUTO_REMOVE β€” status is FALSE + sufficient contradicting evidence
230
+ 2. ALLOW β€” status is TRUE + sufficient supporting evidence
231
+ 3. FLAG_FOR_REVIEW β€” everything else
232
+ """
233
+
234
+ # ── Path 1: AUTO_REMOVE ───────────────────────────────────────────────────
235
+ if status == "FALSE":
236
+
237
+ # Known misinformation pattern: lower bar to 1 source
238
+ if pattern_label is not None and contradicting >= cfg.auto_remove_pattern_min_contra:
239
+ return (
240
+ "AUTO_REMOVE",
241
+ f"Matches known misinformation pattern '{pattern_label}' "
242
+ f"with {contradicting} contradicting source(s).",
243
+ )
244
+
245
+ # Standard: β‰₯2 contradicting sources
246
+ if contradicting >= cfg.auto_remove_min_contra:
247
+ return (
248
+ "AUTO_REMOVE",
249
+ f"{contradicting} contradicting source(s) confirm this claim is false.",
250
+ )
251
+
252
+ # Single very-reliable contradicting source
253
+ if contradicting >= 1 and avg_reliability >= cfg.auto_remove_single_src_rel:
254
+ return (
255
+ "AUTO_REMOVE",
256
+ f"Single highly reliable source (reliability={avg_reliability:.2f}) "
257
+ f"contradicts this claim.",
258
+ )
259
+
260
+ # ── Path 2: ALLOW ─────────────────────────────────────────────────────────
261
+ if status == "TRUE":
262
+
263
+ if supporting >= cfg.allow_min_support:
264
+ return (
265
+ "ALLOW",
266
+ f"{supporting} supporting source(s) confirm this claim is true.",
267
+ )
268
+
269
+ if supporting >= 1 and avg_reliability >= cfg.allow_single_src_rel:
270
+ return (
271
+ "ALLOW",
272
+ f"Single authoritative source (reliability={avg_reliability:.2f}) "
273
+ f"confirms this claim.",
274
+ )
275
+
276
+ # ── Path 3: FLAG_FOR_REVIEW ───────────────────────────────────────────────
277
+ if status == "UNKNOWN":
278
+ reason = "Claim status is UNKNOWN β€” insufficient evidence to decide."
279
+ elif status == "FALSE" and contradicting == 0:
280
+ reason = "Claim is marked FALSE but no contradicting sources were retrieved."
281
+ elif status == "FALSE" and contradicting < cfg.auto_remove_min_contra:
282
+ reason = (
283
+ f"Claim is FALSE but only {contradicting} contradicting source(s) found "
284
+ f"(need β‰₯{cfg.auto_remove_min_contra} for auto-removal)."
285
+ )
286
+ elif status == "TRUE" and supporting < cfg.allow_min_support:
287
+ reason = (
288
+ f"Claim is TRUE but only {supporting} supporting source(s) found "
289
+ f"(need β‰₯{cfg.allow_min_support} for auto-allow)."
290
+ )
291
+ else:
292
+ reason = "Evidence is insufficient or ambiguous."
293
+
294
+ return "FLAG_FOR_REVIEW", reason
295
+
296
+
297
+ # ─────────────────────────────────────────────────────────────────────────────
298
+ # Public API
299
+ # ─────────────────────────────────────────────────────────────────────────────
300
+
301
+ def make_moderation_decision(
302
+ claim_result: dict,
303
+ config: Optional[DecisionConfig] = None,
304
+ ) -> dict:
305
+ """
306
+ Make a final moderation decision for a single claim.
307
+
308
+ Parameters
309
+ ----------
310
+ claim_result : dict
311
+ {
312
+ "claim": str,
313
+ "status": "TRUE" | "FALSE" | "UNKNOWN",
314
+ "confidence": float, # from upstream classifier (0–1)
315
+ "supporting_sources": int,
316
+ "contradicting_sources": int,
317
+ "neutral_sources": int,
318
+ "avg_source_reliability": float, # 0–1
319
+ }
320
+
321
+ config : DecisionConfig | None
322
+ Override default thresholds. Pass None to use defaults.
323
+
324
+ Returns
325
+ -------
326
+ dict
327
+ {
328
+ "decision": "AUTO_REMOVE" | "ALLOW" | "FLAG_FOR_REVIEW",
329
+ "final_confidence": float, # 0.10–0.99
330
+ "reason": str,
331
+ "pattern_matched": str | None, # known misinformation label if triggered
332
+ }
333
+ """
334
+ cfg = config or _DEFAULT_CONFIG
335
+
336
+ # ── Extract fields with safe defaults ─────────────────────────────────────
337
+ claim = str(claim_result.get("claim", ""))
338
+ status = str(claim_result.get("status", "UNKNOWN")).upper()
339
+ confidence = float(claim_result.get("confidence", 0.0))
340
+ supporting = int(claim_result.get("supporting_sources", 0))
341
+ contradicting = int(claim_result.get("contradicting_sources", 0))
342
+ avg_rel = float(claim_result.get("avg_source_reliability", 0.5))
343
+
344
+ # Normalise status to our three canonical values
345
+ if status not in ("TRUE", "FALSE", "UNKNOWN"):
346
+ status = "UNKNOWN"
347
+
348
+ # ── Known misinformation pattern check ────────────────────────────────────
349
+ pattern_label = _match_known_misinfo(claim)
350
+
351
+ # ── Decision ──────────────────────────────────────────────────────────────
352
+ decision, reason_fragment = _decide(
353
+ status, contradicting, supporting, avg_rel, pattern_label, cfg
354
+ )
355
+
356
+ # ── Confidence recalibration ──────────────────────────────────────────────
357
+ final_confidence = _compute_confidence(
358
+ status, confidence, supporting, contradicting,
359
+ avg_rel, pattern_label, cfg,
360
+ )
361
+
362
+ # ── Build human-readable reason ───────────────────────────────────────────
363
+ reason = _build_reason(
364
+ claim, decision, status, final_confidence,
365
+ supporting, contradicting, avg_rel,
366
+ pattern_label, reason_fragment,
367
+ )
368
+
369
+ return {
370
+ "decision": decision,
371
+ "final_confidence": final_confidence,
372
+ "reason": reason,
373
+ "pattern_matched": pattern_label,
374
+ }
375
+
376
+
377
+ def _build_reason(
378
+ claim: str,
379
+ decision: str,
380
+ status: str,
381
+ confidence: float,
382
+ supporting: int,
383
+ contradicting: int,
384
+ avg_rel: float,
385
+ pattern_label: Optional[str],
386
+ core_reason: str,
387
+ ) -> str:
388
+ """Compose a full human-readable explanation."""
389
+ lines = []
390
+
391
+ # Decision headline
392
+ headline = {
393
+ "AUTO_REMOVE": "Content removed: claim is false and clearly contradicted.",
394
+ "ALLOW": "Content allowed: claim is supported by evidence.",
395
+ "FLAG_FOR_REVIEW": "Content flagged: insufficient or ambiguous evidence.",
396
+ }.get(decision, "Decision made.")
397
+ lines.append(headline)
398
+
399
+ # Core reason from decision logic
400
+ lines.append(core_reason)
401
+
402
+ # Evidence summary
403
+ lines.append(
404
+ f"Evidence: {supporting} supporting, {contradicting} contradicting "
405
+ f"(avg source reliability: {avg_rel:.0%})."
406
+ )
407
+
408
+ # Confidence
409
+ lines.append(f"Final confidence: {confidence:.0%}.")
410
+
411
+ return " ".join(lines)
412
+
413
+
414
+ # ─────────────────────────────────────────────────────────────────────────────
415
+ # Batch helper
416
+ # ─────────────────────────────────────────────────────────────────────────────
417
+
418
+ def make_moderation_decisions(
419
+ claim_results: list[dict],
420
+ config: Optional[DecisionConfig] = None,
421
+ ) -> dict:
422
+ """
423
+ Run make_moderation_decision over multiple claims and derive a single
424
+ aggregate content-level decision.
425
+
426
+ Aggregate logic (matches real-world platforms):
427
+ ANY AUTO_REMOVE β†’ content = AUTO_REMOVE
428
+ ANY FLAG_FOR_REVIEW (and no remove) β†’ content = FLAG_FOR_REVIEW
429
+ ALL ALLOW β†’ content = ALLOW
430
+
431
+ Returns
432
+ -------
433
+ {
434
+ "content_decision": "AUTO_REMOVE" | "ALLOW" | "FLAG_FOR_REVIEW",
435
+ "per_claim": [ {claim_result + decision fields}, ... ],
436
+ }
437
+ """
438
+ per_claim = []
439
+ for cr in claim_results:
440
+ result = make_moderation_decision(cr, config)
441
+ per_claim.append({**cr, **result})
442
+
443
+ decisions = [r["decision"] for r in per_claim]
444
+ if "AUTO_REMOVE" in decisions:
445
+ content_decision = "AUTO_REMOVE"
446
+ elif "FLAG_FOR_REVIEW" in decisions:
447
+ content_decision = "FLAG_FOR_REVIEW"
448
+ else:
449
+ content_decision = "ALLOW"
450
+
451
+ return {
452
+ "content_decision": content_decision,
453
+ "per_claim": per_claim,
454
+ }
docker-compose.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # docker-compose.yml β€” local development stack
2
+ # Usage: docker compose up --build
3
+ version: "3.9"
4
+
5
+ services:
6
+ redis:
7
+ image: redis:7-alpine
8
+ restart: unless-stopped
9
+ ports:
10
+ - "6379:6379"
11
+ healthcheck:
12
+ test: ["CMD", "redis-cli", "ping"]
13
+ interval: 10s
14
+ timeout: 3s
15
+ retries: 5
16
+
17
+ env-server:
18
+ build: .
19
+ restart: unless-stopped
20
+ ports:
21
+ - "8000:8000"
22
+ env_file:
23
+ - .env
24
+ environment:
25
+ - REDIS_URL=redis://redis:6379
26
+ depends_on:
27
+ redis:
28
+ condition: service_healthy
29
+ volumes:
30
+ # Mount datasets for persistence across restarts
31
+ - ./datasets:/app/datasets
domain_reliability.yaml ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # domain_reliability.yaml
3
+ #
4
+ # Enterprise-editable domain reliability config.
5
+ # Scores range 0.0 (completely unreliable) to 1.0 (fully authoritative).
6
+ #
7
+ # Rules applied in order; first matching pattern wins.
8
+ # Add your enterprise's internal trusted domains under `custom_trusted`.
9
+ #
10
+ # source_type values:
11
+ # official β†’ government, intergovernmental, standards bodies
12
+ # academic β†’ peer-reviewed journals, universities
13
+ # news β†’ professional journalism outlets
14
+ # factcheck β†’ dedicated fact-checking organisations
15
+ # ngo β†’ non-profit research / advocacy (varies)
16
+ # blog β†’ personal or commercial blogs
17
+ # social β†’ social media platforms
18
+ # unknown β†’ cannot determine
19
+ # =============================================================================
20
+
21
+ version: "1.0"
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # 1. OFFICIAL / GOVERNMENT / INTERGOVERNMENTAL
25
+ # ---------------------------------------------------------------------------
26
+ official:
27
+ score: 0.97
28
+ source_type: official
29
+ domains:
30
+ - who.int
31
+ - cdc.gov
32
+ - nih.gov
33
+ - fda.gov
34
+ - nhs.uk
35
+ - ecdc.europa.eu
36
+ - paho.org
37
+ - un.org
38
+ - unicef.org
39
+ - worldbank.org
40
+ - imf.org
41
+ - oecd.org
42
+ - europa.eu
43
+ - gov.uk
44
+ - canada.ca
45
+ - australia.gov.au
46
+ - rki.de
47
+ - france.fr
48
+ - nasa.gov
49
+ - noaa.gov
50
+ - epa.gov
51
+ - energy.gov
52
+ - ftc.gov
53
+ - sec.gov
54
+ - federalreserve.gov
55
+ - bls.gov
56
+ - census.gov
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # 2. ACADEMIC / PEER-REVIEWED
60
+ # ---------------------------------------------------------------------------
61
+ academic:
62
+ score: 0.93
63
+ source_type: academic
64
+ domains:
65
+ - nature.com
66
+ - science.org
67
+ - cell.com
68
+ - thelancet.com
69
+ - nejm.org
70
+ - bmj.com
71
+ - jamanetwork.com
72
+ - acs.org
73
+ - ieee.org
74
+ - acm.org
75
+ - pubmed.ncbi.nlm.nih.gov
76
+ - scholar.google.com
77
+ - arxiv.org
78
+ - biorxiv.org
79
+ - medrxiv.org
80
+ - plos.org
81
+ - frontiersin.org
82
+ - springer.com
83
+ - wiley.com
84
+ - elsevier.com
85
+ - oxfordjournals.org
86
+ - cambridge.org
87
+ - mit.edu
88
+ - stanford.edu
89
+ - harvard.edu
90
+ - ox.ac.uk
91
+ - cam.ac.uk
92
+ - ucl.ac.uk
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # 3. DEDICATED FACT-CHECKERS (IFCN signatories)
96
+ # ---------------------------------------------------------------------------
97
+ factcheck:
98
+ score: 0.90
99
+ source_type: factcheck
100
+ domains:
101
+ - snopes.com
102
+ - factcheck.org
103
+ - politifact.com
104
+ - fullfact.org
105
+ - reuters.com/fact-check
106
+ - apnews.com
107
+ - afp.com
108
+ - misbar.com
109
+ - boomlive.in
110
+ - factly.in
111
+ - africacheck.org
112
+ - chequeado.com
113
+ - correctiv.org
114
+ - dpa.com
115
+ - leadstories.com
116
+ - verafiles.org
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # 4. ESTABLISHED NEWS ORGANISATIONS
120
+ # ---------------------------------------------------------------------------
121
+ news_tier1:
122
+ score: 0.82
123
+ source_type: news
124
+ domains:
125
+ - bbc.com
126
+ - bbc.co.uk
127
+ - reuters.com
128
+ - apnews.com
129
+ - theguardian.com
130
+ - nytimes.com
131
+ - wsj.com
132
+ - washingtonpost.com
133
+ - economist.com
134
+ - ft.com
135
+ - bloomberg.com
136
+ - time.com
137
+ - npr.org
138
+ - pbs.org
139
+ - theatlantic.com
140
+ - science.sciencemag.org
141
+ - scientificamerican.com
142
+ - newscientist.com
143
+ - wired.com
144
+ - techcrunch.com
145
+ - arstechnica.com
146
+ - technologyreview.mit.edu
147
+ - spiegel.de
148
+ - lemonde.fr
149
+ - corriere.it
150
+ - elpais.com
151
+ - sueddeutsche.de
152
+
153
+ news_tier2:
154
+ score: 0.68
155
+ source_type: news
156
+ domains:
157
+ - huffpost.com
158
+ - vice.com
159
+ - vox.com
160
+ - slate.com
161
+ - salon.com
162
+ - thehill.com
163
+ - politico.com
164
+ - axios.com
165
+ - buzzfeednews.com
166
+ - businessinsider.com
167
+ - newsweek.com
168
+ - usatoday.com
169
+ - nbcnews.com
170
+ - cbsnews.com
171
+ - abcnews.go.com
172
+ - foxnews.com
173
+ - msnbc.com
174
+ - cnn.com
175
+ - dailymail.co.uk
176
+ - mirror.co.uk
177
+ - independent.co.uk
178
+ - telegraph.co.uk
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # 5. NGOs / THINK TANKS (variable bias, moderate reliability)
182
+ # ---------------------------------------------------------------------------
183
+ ngo:
184
+ score: 0.60
185
+ source_type: ngo
186
+ domains:
187
+ - amnesty.org
188
+ - hrw.org
189
+ - msf.org
190
+ - oxfam.org
191
+ - brookings.edu
192
+ - cfr.org
193
+ - rand.org
194
+ - pewresearch.org
195
+ - ipsos.com
196
+ - gallup.com
197
+ - kff.org
198
+ - urban.org
199
+ - chathamhouse.org
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # 6. SOCIAL MEDIA PLATFORMS (user-generated; low inherent reliability)
203
+ # ---------------------------------------------------------------------------
204
+ social:
205
+ score: 0.15
206
+ source_type: social
207
+ domains:
208
+ - twitter.com
209
+ - x.com
210
+ - facebook.com
211
+ - instagram.com
212
+ - tiktok.com
213
+ - reddit.com
214
+ - youtube.com
215
+ - telegram.org
216
+ - t.me
217
+ - threads.net
218
+ - linkedin.com
219
+ - pinterest.com
220
+ - tumblr.com
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # 7. KNOWN MISINFORMATION / LOW-CREDIBILITY DOMAINS
224
+ # ---------------------------------------------------------------------------
225
+ low_credibility:
226
+ score: 0.08
227
+ source_type: blog
228
+ domains:
229
+ - naturalnews.com
230
+ - infowars.com
231
+ - breitbart.com
232
+ - thegatewayp.com
233
+ - beforeitsnews.com
234
+ - zerohedge.com
235
+ - activistpost.com
236
+ - globalresearch.ca
237
+ - off-guardian.org
238
+ - thetruthseeker.co.uk
239
+ - conspiracytheories.net
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # 8. GENERIC BLOG / CMS PATTERNS (matched by TLD pattern, not exact domain)
243
+ # ---------------------------------------------------------------------------
244
+ blog_patterns:
245
+ score: 0.18
246
+ source_type: blog
247
+ patterns:
248
+ - "*.blogspot.com"
249
+ - "*.wordpress.com"
250
+ - "*.medium.com"
251
+ - "*.substack.com"
252
+ - "*.ghost.io"
253
+ - "*.wixsite.com"
254
+ - "*.weebly.com"
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # 9. ENTERPRISE CUSTOM β€” add your internal trusted domains here
258
+ # ---------------------------------------------------------------------------
259
+ custom_trusted:
260
+ score: 0.95
261
+ source_type: official
262
+ domains: []
263
+ # Example:
264
+ # - trust-center.mycompany.com
265
+ # - internal-wiki.corp.example.com
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # DEFAULTS (applied when no pattern matches)
269
+ # ---------------------------------------------------------------------------
270
+ defaults:
271
+ unknown_domain_score: 0.35
272
+ unknown_source_type: unknown
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # STANCE CLASSIFICATION THRESHOLDS
276
+ # ---------------------------------------------------------------------------
277
+ # NOTE: These keyword lists are now used by Layer 2 of stance_classifier.py.
278
+ # Layer 1 (semantic heuristics) and Layer 3 (Claude LLM) take priority.
279
+ # These lists serve as the fallback when both other layers are unavailable.
280
+ stance_thresholds:
281
+ # Threshold=1: any single strong-signal keyword is decisive
282
+ contradicting_keyword_min: 1
283
+ supporting_keyword_min: 1
284
+
285
+ # Strong contradicting signals (one hit = contradicting)
286
+ contradicting_keywords:
287
+ - "false"
288
+ - "debunked"
289
+ - "disproven"
290
+ - "misinformation"
291
+ - "misleading"
292
+ - "no evidence"
293
+ - "lacks evidence"
294
+ - "unfounded"
295
+ - "refuted"
296
+ - "myth"
297
+ - "conspiracy theory"
298
+ - "fact-check"
299
+ - "factcheck"
300
+ - "incorrect"
301
+ - "not true"
302
+ - "inaccurate"
303
+ - "unproven"
304
+ - "pseudoscience"
305
+ - "hoax"
306
+ - "wrong"
307
+ - "fake"
308
+ - "fabricated"
309
+ - "baseless"
310
+ - "discredited"
311
+ - "debunks"
312
+ - "false claim"
313
+ - "does not cause"
314
+ - "no link"
315
+ - "unrelated"
316
+ - "no connection"
317
+ - "scientists say false"
318
+ - "experts say false"
319
+ - "is not true"
320
+ - "is not real"
321
+
322
+ # Strong supporting signals (one hit = supporting)
323
+ supporting_keywords:
324
+ - "confirms"
325
+ - "confirmed"
326
+ - "verified"
327
+ - "validated"
328
+ - "proven"
329
+ - "evidence shows"
330
+ - "data shows"
331
+ - "research shows"
332
+ - "study finds"
333
+ - "scientists confirm"
334
+ - "officially announced"
335
+ - "official statement"
336
+ - "press release"
337
+ - "gazette notification"
338
+ - "government confirms"
339
+ - "approved"
340
+ - "endorsed"
341
+ - "supported by evidence"
342
+ - "according to official"
343
+ - "ministry confirms"
344
+ - "is true"
345
+ - "is real"
346
+ - "has been confirmed"
347
+ - "study confirms"
348
+ - "research confirms"
349
+ - "data confirms"
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # EVIDENCE SUFFICIENCY (used by environment to set status_flags)
353
+ # ---------------------------------------------------------------------------
354
+ evidence_thresholds:
355
+ min_sources_for_decision: 2
356
+ min_claims_verified_fraction: 0.5 # at least 50% of claims must be verified
357
+ min_reliable_source_score: 0.6 # at least one source must exceed this
enhanced_stance_classifier.py ADDED
@@ -0,0 +1,798 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ enhanced_stance_classifier.py
3
+ =============================
4
+ Advanced stance classification system with improved context understanding,
5
+ multi-template NLI, and confidence-based decision making.
6
+
7
+ Key improvements over the original stance_classifier.py:
8
+ 1. Enhanced text preprocessing with intelligent chunking
9
+ 2. Multiple NLI templates for different claim types
10
+ 3. Better context extraction and relevance scoring
11
+ 4. Confidence-based aggregation with fallback strategies
12
+ 5. Domain-specific semantic rules for common misinformation topics
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import logging
19
+ import os
20
+ import re
21
+ import time
22
+ from typing import Dict, List, Optional, Tuple, Any
23
+ from dataclasses import dataclass
24
+ from enum import Enum
25
+
26
+ logger = logging.getLogger("enhanced_stance_classifier")
27
+
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ # Enhanced Text Processing
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ class TextProcessor:
33
+ """Advanced text preprocessing with intelligent chunking and cleaning."""
34
+
35
+ def __init__(self):
36
+ self.sentence_endings = re.compile(r'[.!?]+\s+')
37
+ self.noise_patterns = [
38
+ re.compile(r'\[.*?\]'), # Remove bracketed content
39
+ re.compile(r'\(.*?\)'), # Remove parenthetical content (optional)
40
+ re.compile(r'https?://\S+'), # Remove URLs
41
+ re.compile(r'@\w+'), # Remove mentions
42
+ re.compile(r'#\w+'), # Remove hashtags
43
+ re.compile(r'\s+'), # Normalize whitespace
44
+ ]
45
+
46
+ def clean_text(self, text: str) -> str:
47
+ """Clean and normalize text for better processing."""
48
+ if not text:
49
+ return ""
50
+
51
+ # Remove noise patterns
52
+ cleaned = text
53
+ for pattern in self.noise_patterns[:-1]: # Don't apply whitespace normalization yet
54
+ cleaned = pattern.sub(' ', cleaned)
55
+
56
+ # Normalize whitespace (last pattern)
57
+ cleaned = self.noise_patterns[-1].sub(' ', cleaned).strip()
58
+
59
+ return cleaned
60
+
61
+ def intelligent_chunk(self, text: str, max_length: int = 512, overlap: int = 50) -> List[str]:
62
+ """
63
+ Split text into chunks at sentence boundaries while preserving context.
64
+
65
+ Args:
66
+ text: Input text to chunk
67
+ max_length: Maximum characters per chunk
68
+ overlap: Characters to overlap between chunks for context
69
+
70
+ Returns:
71
+ List of text chunks with preserved sentence boundaries
72
+ """
73
+ if len(text) <= max_length:
74
+ return [text]
75
+
76
+ sentences = self.sentence_endings.split(text)
77
+ chunks = []
78
+ current_chunk = ""
79
+
80
+ for sentence in sentences:
81
+ sentence = sentence.strip()
82
+ if not sentence:
83
+ continue
84
+
85
+ # If adding this sentence would exceed max_length, start a new chunk
86
+ if len(current_chunk) + len(sentence) > max_length and current_chunk:
87
+ chunks.append(current_chunk.strip())
88
+
89
+ # Start new chunk with overlap from previous chunk
90
+ if overlap > 0 and len(current_chunk) > overlap:
91
+ current_chunk = current_chunk[-overlap:] + " " + sentence
92
+ else:
93
+ current_chunk = sentence
94
+ else:
95
+ current_chunk += " " + sentence if current_chunk else sentence
96
+
97
+ # Add the last chunk
98
+ if current_chunk.strip():
99
+ chunks.append(current_chunk.strip())
100
+
101
+ return chunks if chunks else [text[:max_length]]
102
+
103
+ def extract_key_entities(self, text: str) -> List[str]:
104
+ """Extract key entities and phrases from text."""
105
+ # Simple entity extraction - can be enhanced with NER models
106
+ entities = []
107
+
108
+ # Extract capitalized phrases (potential proper nouns)
109
+ capitalized = re.findall(r'\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b', text)
110
+ entities.extend(capitalized)
111
+
112
+ # Extract quoted phrases
113
+ quoted = re.findall(r'"([^"]+)"', text)
114
+ entities.extend(quoted)
115
+
116
+ # Extract numbers with units
117
+ numbers = re.findall(r'\b\d+(?:\.\d+)?\s*(?:%|percent|million|billion|thousand)\b', text, re.I)
118
+ entities.extend(numbers)
119
+
120
+ return list(set(entities))
121
+
122
+ # ─────────────────────────────────────────────────────────────────────────────
123
+ # Enhanced Semantic Rules
124
+ # ─────────────────────────────────────────────────────────────────────────────
125
+
126
+ class ClaimType(Enum):
127
+ HEALTH = "health"
128
+ POLITICS = "politics"
129
+ SCIENCE = "science"
130
+ TECHNOLOGY = "technology"
131
+ CLIMATE = "climate"
132
+ ECONOMICS = "economics"
133
+ GENERAL = "general"
134
+
135
+ @dataclass
136
+ class SemanticRule:
137
+ claim_pattern: str
138
+ source_pattern: str
139
+ stance: str # "supporting" or "contradicting"
140
+ confidence: float
141
+ claim_types: List[ClaimType]
142
+
143
+ class EnhancedSemanticLayer:
144
+ """
145
+ Advanced semantic heuristics with domain-specific rules and better pattern matching.
146
+ """
147
+
148
+ def __init__(self):
149
+ self.rules = self._build_enhanced_rules()
150
+ self.text_processor = TextProcessor()
151
+
152
+ def _build_enhanced_rules(self) -> List[SemanticRule]:
153
+ """Build comprehensive semantic rules for different domains."""
154
+ rules = []
155
+
156
+ # Health/Medical misinformation rules
157
+ health_rules = [
158
+ SemanticRule(
159
+ claim_pattern=r"\bvaccine.{0,30}(cause|causes|causing).{0,30}(autism|death|cancer|infertility|dna)",
160
+ source_pattern=r"\b(no\s+evidence|debunked|safe|clinical\s+trial|fda\s+approved|peer\s+reviewed)\b",
161
+ stance="contradicting",
162
+ confidence=0.9,
163
+ claim_types=[ClaimType.HEALTH]
164
+ ),
165
+ SemanticRule(
166
+ claim_pattern=r"\bcovid.{0,20}(hoax|fake|planned|bioweapon)",
167
+ source_pattern=r"\b(who|cdc|health\s+officials|scientific\s+consensus|peer\s+reviewed)\b",
168
+ stance="contradicting",
169
+ confidence=0.85,
170
+ claim_types=[ClaimType.HEALTH]
171
+ ),
172
+ SemanticRule(
173
+ claim_pattern=r"\b(cure|treatment|remedy).{0,30}(cancer|covid|aids|diabetes)",
174
+ source_pattern=r"\b(no\s+evidence|unproven|not\s+approved|clinical\s+trial|fda)\b",
175
+ stance="contradicting",
176
+ confidence=0.8,
177
+ claim_types=[ClaimType.HEALTH]
178
+ ),
179
+ ]
180
+
181
+ # Technology misinformation rules
182
+ tech_rules = [
183
+ SemanticRule(
184
+ claim_pattern=r"\b5g.{0,30}(cause|spread|transmit).{0,30}(covid|cancer|virus|radiation)",
185
+ source_pattern=r"\b(no\s+link|no\s+evidence|debunked|scientifically\s+impossible|who\s+says)\b",
186
+ stance="contradicting",
187
+ confidence=0.9,
188
+ claim_types=[ClaimType.TECHNOLOGY]
189
+ ),
190
+ SemanticRule(
191
+ claim_pattern=r"\bmicrochip.{0,30}(vaccine|implant|track|control)",
192
+ source_pattern=r"\b(conspiracy|debunked|no\s+evidence|fact\s+check|impossible)\b",
193
+ stance="contradicting",
194
+ confidence=0.85,
195
+ claim_types=[ClaimType.TECHNOLOGY]
196
+ ),
197
+ ]
198
+
199
+ # Climate misinformation rules
200
+ climate_rules = [
201
+ SemanticRule(
202
+ claim_pattern=r"\bclimate\s+change.{0,20}(hoax|fake|not\s+real|natural)",
203
+ source_pattern=r"\b(scientific\s+consensus|97\s*%|ipcc|nasa|noaa|peer\s+reviewed)\b",
204
+ stance="contradicting",
205
+ confidence=0.9,
206
+ claim_types=[ClaimType.CLIMATE]
207
+ ),
208
+ SemanticRule(
209
+ claim_pattern=r"\bglobal\s+warming.{0,20}(stopped|pause|cooling)",
210
+ source_pattern=r"\b(temperature\s+records|warmest\s+year|data\s+shows|scientists\s+confirm)\b",
211
+ stance="contradicting",
212
+ confidence=0.8,
213
+ claim_types=[ClaimType.CLIMATE]
214
+ ),
215
+ ]
216
+
217
+ # Political misinformation rules
218
+ political_rules = [
219
+ SemanticRule(
220
+ claim_pattern=r"\belection.{0,30}(stolen|rigged|fraud|fake)",
221
+ source_pattern=r"\b(courts?\s+reject|no\s+evidence|audit\s+confirms|official\s+results)\b",
222
+ stance="contradicting",
223
+ confidence=0.85,
224
+ claim_types=[ClaimType.POLITICS]
225
+ ),
226
+ SemanticRule(
227
+ claim_pattern=r"\bgovernment.{0,30}(announce|confirm|official)",
228
+ source_pattern=r"\b(official\s+statement|government\s+website|press\s+release|ministry)\b",
229
+ stance="supporting",
230
+ confidence=0.8,
231
+ claim_types=[ClaimType.POLITICS]
232
+ ),
233
+ ]
234
+
235
+ rules.extend(health_rules + tech_rules + climate_rules + political_rules)
236
+ return rules
237
+
238
+ def classify_claim_type(self, claim: str) -> ClaimType:
239
+ """Classify the type of claim to apply appropriate rules."""
240
+ claim_lower = claim.lower()
241
+
242
+ health_keywords = ["vaccine", "covid", "virus", "cancer", "health", "medical", "drug", "treatment"]
243
+ tech_keywords = ["5g", "microchip", "ai", "technology", "internet", "phone", "radiation"]
244
+ climate_keywords = ["climate", "warming", "carbon", "emission", "fossil", "renewable", "temperature"]
245
+ political_keywords = ["election", "government", "president", "vote", "congress", "parliament", "policy"]
246
+ science_keywords = ["study", "research", "scientist", "experiment", "evidence", "data", "peer review"]
247
+ economics_keywords = ["economy", "inflation", "gdp", "unemployment", "market", "recession", "financial"]
248
+
249
+ keyword_sets = [
250
+ (health_keywords, ClaimType.HEALTH),
251
+ (tech_keywords, ClaimType.TECHNOLOGY),
252
+ (climate_keywords, ClaimType.CLIMATE),
253
+ (political_keywords, ClaimType.POLITICS),
254
+ (science_keywords, ClaimType.SCIENCE),
255
+ (economics_keywords, ClaimType.ECONOMICS),
256
+ ]
257
+
258
+ max_matches = 0
259
+ best_type = ClaimType.GENERAL
260
+
261
+ for keywords, claim_type in keyword_sets:
262
+ matches = sum(1 for kw in keywords if kw in claim_lower)
263
+ if matches > max_matches:
264
+ max_matches = matches
265
+ best_type = claim_type
266
+
267
+ return best_type
268
+
269
+ def classify(self, claim: str, source_text: str) -> Optional[Tuple[str, float]]:
270
+ """
271
+ Enhanced semantic classification with domain-specific rules.
272
+
273
+ Returns:
274
+ (stance, confidence) or None if no rule matches
275
+ """
276
+ claim_type = self.classify_claim_type(claim)
277
+ claim_lower = claim.lower()
278
+ source_lower = source_text.lower()
279
+
280
+ # Apply rules relevant to the claim type
281
+ for rule in self.rules:
282
+ if claim_type in rule.claim_types or ClaimType.GENERAL in rule.claim_types:
283
+ if re.search(rule.claim_pattern, claim_lower) and re.search(rule.source_pattern, source_lower):
284
+ logger.debug(
285
+ "Enhanced semantic rule matched: %s -> %s (conf=%.2f)",
286
+ rule.claim_pattern[:40], rule.stance, rule.confidence
287
+ )
288
+ return rule.stance, rule.confidence
289
+
290
+ return None
291
+
292
+ # ─────────────────────────────────────────────────────────────────────────────
293
+ # Multi-Template NLI Layer
294
+ # ─────────────────────────────────────────────────────────────────────────────
295
+
296
+ class NLITemplateManager:
297
+ """Manages multiple NLI templates for different claim types and contexts."""
298
+
299
+ def __init__(self):
300
+ self.templates = self._build_templates()
301
+
302
+ def _build_templates(self) -> Dict[ClaimType, List[str]]:
303
+ """Build claim-type specific NLI templates."""
304
+ return {
305
+ ClaimType.HEALTH: [
306
+ "This text confirms that {}",
307
+ "This text disproves that {}",
308
+ "According to this source, {} is true",
309
+ "According to this source, {} is false",
310
+ "Medical evidence shows that {}",
311
+ "Medical evidence contradicts that {}",
312
+ ],
313
+ ClaimType.SCIENCE: [
314
+ "Scientific research confirms that {}",
315
+ "Scientific research contradicts that {}",
316
+ "Studies show that {}",
317
+ "Studies disprove that {}",
318
+ "Evidence supports that {}",
319
+ "Evidence refutes that {}",
320
+ ],
321
+ ClaimType.POLITICS: [
322
+ "Official sources confirm that {}",
323
+ "Official sources deny that {}",
324
+ "Government records show that {}",
325
+ "Government records contradict that {}",
326
+ "Political analysis supports that {}",
327
+ "Political analysis disputes that {}",
328
+ ],
329
+ ClaimType.TECHNOLOGY: [
330
+ "Technical analysis confirms that {}",
331
+ "Technical analysis disproves that {}",
332
+ "Technology experts agree that {}",
333
+ "Technology experts dispute that {}",
334
+ "Research demonstrates that {}",
335
+ "Research contradicts that {}",
336
+ ],
337
+ ClaimType.CLIMATE: [
338
+ "Climate data confirms that {}",
339
+ "Climate data contradicts that {}",
340
+ "Environmental research shows that {}",
341
+ "Environmental research disproves that {}",
342
+ "Scientific consensus supports that {}",
343
+ "Scientific consensus rejects that {}",
344
+ ],
345
+ ClaimType.GENERAL: [
346
+ "This text supports the claim that {}",
347
+ "This text contradicts the claim that {}",
348
+ "Evidence shows that {}",
349
+ "Evidence disproves that {}",
350
+ "This information confirms that {}",
351
+ "This information refutes that {}",
352
+ ]
353
+ }
354
+
355
+ def get_templates(self, claim_type: ClaimType) -> List[str]:
356
+ """Get appropriate templates for a claim type."""
357
+ return self.templates.get(claim_type, self.templates[ClaimType.GENERAL])
358
+
359
+ class EnhancedNLILayer:
360
+ """
361
+ Enhanced NLI layer with multiple templates, better preprocessing, and confidence calibration.
362
+ """
363
+
364
+ def __init__(self):
365
+ self.template_manager = NLITemplateManager()
366
+ self.text_processor = TextProcessor()
367
+ self._cache: Dict[str, Tuple[str, float]] = {}
368
+ self._enabled = os.getenv("STANCE_NLI_ENABLED", "true").lower() != "false"
369
+
370
+ def available(self) -> bool:
371
+ return self._enabled
372
+
373
+ def classify(self, claim: str, title: str, text: str, claim_type: ClaimType = ClaimType.GENERAL) -> Optional[Tuple[str, float]]:
374
+ """
375
+ Enhanced NLI classification with multiple templates and better preprocessing.
376
+
377
+ Returns:
378
+ (stance, confidence) or None if classification fails
379
+ """
380
+ if not self._enabled:
381
+ return None
382
+
383
+ # Create cache key β€” use more text to discriminate full articles
384
+ cache_key = hashlib.md5(
385
+ f"{claim[:120]}||{title[:80]}||{text[:600]}||{claim_type.value}".encode()
386
+ ).hexdigest()
387
+
388
+ if cache_key in self._cache:
389
+ return self._cache[cache_key]
390
+
391
+ # Clean and chunk the text β€” use larger chunks to handle full articles
392
+ combined_text = self.text_processor.clean_text(f"{title} {text}")
393
+ text_chunks = self.text_processor.intelligent_chunk(combined_text, max_length=1000)
394
+
395
+ # Get appropriate templates for this claim type
396
+ templates = self.template_manager.get_templates(claim_type)
397
+
398
+ # Try multiple approaches and aggregate results
399
+ results = []
400
+
401
+ for chunk in text_chunks[:5]: # Process up to 5 chunks to cover full articles
402
+ for template_pair in [templates[i:i+2] for i in range(0, len(templates), 2)]:
403
+ if len(template_pair) == 2:
404
+ support_template, contradict_template = template_pair
405
+ result = self._classify_with_templates(claim, chunk, support_template, contradict_template)
406
+ if result:
407
+ results.append(result)
408
+
409
+ if not results:
410
+ return None
411
+
412
+ # Aggregate results with confidence weighting
413
+ final_stance, final_confidence = self._aggregate_results(results)
414
+
415
+ # Cache the result
416
+ self._cache[cache_key] = (final_stance, final_confidence)
417
+
418
+ logger.debug(
419
+ "Enhanced NLI: '%s...' -> %s (conf=%.2f) from %d results",
420
+ claim[:40], final_stance, final_confidence, len(results)
421
+ )
422
+
423
+ return final_stance, final_confidence
424
+
425
+ def _classify_with_templates(self, claim: str, text: str, support_template: str, contradict_template: str) -> Optional[Tuple[str, float]]:
426
+ """Classify using a pair of support/contradict templates."""
427
+ try:
428
+ # Try HF API first, then local transformers
429
+ result = self._hf_api_classify(claim, text, support_template, contradict_template)
430
+ if result:
431
+ return result
432
+
433
+ result = self._local_classify(claim, text, support_template, contradict_template)
434
+ return result
435
+
436
+ except Exception as exc:
437
+ logger.debug("Template classification failed: %s", exc)
438
+ return None
439
+
440
+ def _hf_api_classify(self, claim: str, text: str, support_template: str, contradict_template: str) -> Optional[Tuple[str, float]]:
441
+ """Use HuggingFace API for classification."""
442
+ try:
443
+ import requests
444
+ except ImportError:
445
+ return None
446
+
447
+ hf_token = os.getenv("HF_TOKEN", "")
448
+ if not hf_token or hf_token.startswith("your_"):
449
+ return None
450
+
451
+ headers = {
452
+ "Content-Type": "application/json",
453
+ "Authorization": f"Bearer {hf_token}",
454
+ }
455
+
456
+ # Use zero-shot classification with custom templates
457
+ payload = {
458
+ "inputs": text,
459
+ "parameters": {
460
+ "candidate_labels": [
461
+ support_template.format(claim),
462
+ contradict_template.format(claim),
463
+ "This text is unrelated to the claim"
464
+ ],
465
+ "multi_label": False,
466
+ },
467
+ }
468
+
469
+ try:
470
+ resp = requests.post(
471
+ "https://api-inference.huggingface.co/models/facebook/bart-large-mnli",
472
+ headers=headers,
473
+ json=payload,
474
+ timeout=15
475
+ )
476
+
477
+ if resp.status_code in (503, 502):
478
+ return None
479
+
480
+ if not resp.ok:
481
+ return None
482
+
483
+ data = resp.json()
484
+ return self._parse_hf_response(data)
485
+
486
+ except Exception:
487
+ return None
488
+
489
+ def _local_classify(self, claim: str, text: str, support_template: str, contradict_template: str) -> Optional[Tuple[str, float]]:
490
+ """Use local transformers for classification."""
491
+ try:
492
+ from transformers import pipeline
493
+
494
+ # Initialize pipeline if not already done
495
+ if not hasattr(self, '_local_pipeline'):
496
+ self._local_pipeline = pipeline(
497
+ "zero-shot-classification",
498
+ model="facebook/bart-large-mnli",
499
+ device=-1 # CPU
500
+ )
501
+
502
+ result = self._local_pipeline(
503
+ text,
504
+ candidate_labels=[
505
+ support_template.format(claim),
506
+ contradict_template.format(claim),
507
+ "unrelated"
508
+ ]
509
+ )
510
+
511
+ labels = result["labels"]
512
+ scores = result["scores"]
513
+
514
+ if "unrelated" in labels[0].lower():
515
+ return "neutral", float(scores[0])
516
+ elif support_template.format("").strip() in labels[0]:
517
+ return "supporting", float(scores[0])
518
+ else:
519
+ return "contradicting", float(scores[0])
520
+
521
+ except Exception:
522
+ return None
523
+
524
+ def _parse_hf_response(self, data: dict) -> Optional[Tuple[str, float]]:
525
+ """Parse HuggingFace API response."""
526
+ if not data or not isinstance(data, dict):
527
+ return None
528
+
529
+ labels = data.get("labels", [])
530
+ scores = data.get("scores", [])
531
+
532
+ if not labels or not scores:
533
+ return None
534
+
535
+ top_label = labels[0].lower()
536
+ top_score = float(scores[0])
537
+
538
+ if "unrelated" in top_label:
539
+ return "neutral", top_score
540
+ elif any(word in top_label for word in ["confirms", "supports", "true", "shows"]):
541
+ return "supporting", top_score
542
+ else:
543
+ return "contradicting", top_score
544
+
545
+ def _aggregate_results(self, results: List[Tuple[str, float]]) -> Tuple[str, float]:
546
+ """Aggregate multiple NLI results with confidence weighting."""
547
+ if not results:
548
+ return "neutral", 0.2
549
+
550
+ # Weight results by confidence
551
+ stance_scores = {"supporting": 0.0, "contradicting": 0.0, "neutral": 0.0}
552
+ total_weight = 0.0
553
+
554
+ for stance, confidence in results:
555
+ stance_scores[stance] += confidence
556
+ total_weight += confidence
557
+
558
+ if total_weight == 0:
559
+ return "neutral", 0.2
560
+
561
+ # Normalize scores
562
+ for stance in stance_scores:
563
+ stance_scores[stance] /= total_weight
564
+
565
+ # Find the highest scoring stance
566
+ best_stance = max(stance_scores, key=stance_scores.get)
567
+ best_score = stance_scores[best_stance]
568
+
569
+ # Adjust confidence based on margin and number of results
570
+ margin = best_score - max(score for stance, score in stance_scores.items() if stance != best_stance)
571
+ confidence = min(0.95, best_score + margin * 0.1 + len(results) * 0.05)
572
+
573
+ return best_stance, confidence
574
+
575
+ # ─────────────────────────────────────────────────────────────────────────────
576
+ # Enhanced Keyword Layer
577
+ # ─────────────────────────────────────────────────────────────────────────────
578
+
579
+ class EnhancedKeywordLayer:
580
+ """Enhanced keyword matching with context awareness and better thresholds."""
581
+
582
+ def __init__(self):
583
+ self.strong_contra = {
584
+ "false", "debunked", "disproven", "misinformation", "misleading",
585
+ "no evidence", "lacks evidence", "unfounded", "refuted", "myth",
586
+ "conspiracy theory", "fact-check", "factcheck", "incorrect",
587
+ "not true", "inaccurate", "unproven", "pseudoscience", "hoax",
588
+ "wrong", "fake", "fabricated", "baseless", "discredited",
589
+ "impossible", "scientifically impossible", "medically impossible"
590
+ }
591
+
592
+ self.weak_contra = {
593
+ "disputed", "controversial", "unverified", "claims", "alleged",
594
+ "questioned", "skeptics", "critics", "challenged", "contradicts",
595
+ "refutes", "rejects", "warns against", "cautioned", "doubtful",
596
+ "uncertain", "unclear", "mixed evidence", "conflicting"
597
+ }
598
+
599
+ self.strong_support = {
600
+ "confirms", "confirmed", "verified", "validated", "proven",
601
+ "evidence shows", "data shows", "research shows", "study finds",
602
+ "scientists confirm", "officially announced", "official statement",
603
+ "press release", "gazette notification", "government confirms",
604
+ "approved", "endorsed", "supported by evidence", "peer reviewed",
605
+ "clinical trial", "fda approved", "who approved", "scientifically proven"
606
+ }
607
+
608
+ self.weak_support = {
609
+ "suggests", "indicates", "shows", "demonstrates", "according to",
610
+ "reports", "states", "announced", "published", "found that",
611
+ "appears", "seems", "likely", "probably", "research indicates",
612
+ "studies suggest", "data indicates", "preliminary results"
613
+ }
614
+
615
+ self.context_modifiers = {
616
+ "negation": ["not", "no", "never", "none", "neither", "without"],
617
+ "uncertainty": ["may", "might", "could", "possibly", "perhaps", "maybe"],
618
+ "emphasis": ["definitely", "certainly", "absolutely", "clearly", "obviously"]
619
+ }
620
+
621
+ def classify(self, text: str, claim: str = "") -> Tuple[str, float]:
622
+ """
623
+ Enhanced keyword classification with context awareness.
624
+
625
+ Returns:
626
+ (stance, confidence)
627
+ """
628
+ text_lower = text.lower()
629
+
630
+ # Count keyword matches
631
+ strong_contra_count = sum(1 for kw in self.strong_contra if kw in text_lower)
632
+ weak_contra_count = sum(1 for kw in self.weak_contra if kw in text_lower)
633
+ strong_support_count = sum(1 for kw in self.strong_support if kw in text_lower)
634
+ weak_support_count = sum(1 for kw in self.weak_support if kw in text_lower)
635
+
636
+ # Check for context modifiers
637
+ negation_count = sum(1 for kw in self.context_modifiers["negation"] if kw in text_lower)
638
+ uncertainty_count = sum(1 for kw in self.context_modifiers["uncertainty"] if kw in text_lower)
639
+ emphasis_count = sum(1 for kw in self.context_modifiers["emphasis"] if kw in text_lower)
640
+
641
+ # Calculate weighted scores
642
+ contra_score = strong_contra_count * 3 + weak_contra_count * 1
643
+ support_score = strong_support_count * 3 + weak_support_count * 1
644
+
645
+ # Apply context modifiers
646
+ if negation_count > 0:
647
+ # Negation can flip the meaning
648
+ if contra_score > support_score:
649
+ contra_score *= 0.5 # Reduce contradiction confidence
650
+ elif support_score > contra_score:
651
+ support_score *= 0.5 # Reduce support confidence
652
+
653
+ # Uncertainty reduces confidence
654
+ uncertainty_penalty = min(0.3, uncertainty_count * 0.1)
655
+
656
+ # Emphasis increases confidence
657
+ emphasis_bonus = min(0.2, emphasis_count * 0.05)
658
+
659
+ # Determine stance and confidence
660
+ if strong_contra_count >= 1 and contra_score > support_score:
661
+ base_confidence = min(0.9, 0.6 + strong_contra_count * 0.1)
662
+ confidence = base_confidence - uncertainty_penalty + emphasis_bonus
663
+ return "contradicting", max(0.3, confidence)
664
+
665
+ if strong_support_count >= 1 and support_score > contra_score:
666
+ base_confidence = min(0.9, 0.6 + strong_support_count * 0.1)
667
+ confidence = base_confidence - uncertainty_penalty + emphasis_bonus
668
+ return "supporting", max(0.3, confidence)
669
+
670
+ if contra_score >= 2 and contra_score > support_score:
671
+ confidence = 0.5 - uncertainty_penalty + emphasis_bonus
672
+ return "contradicting", max(0.2, confidence)
673
+
674
+ if support_score >= 2 and support_score > contra_score:
675
+ confidence = 0.5 - uncertainty_penalty + emphasis_bonus
676
+ return "supporting", max(0.2, confidence)
677
+
678
+ # Default to neutral with low confidence
679
+ return "neutral", 0.2
680
+
681
+ # ─────────────────────────────────────────────────────────────────────────────
682
+ # Main Enhanced Stance Classifier
683
+ # ─────────────────────────────────────────────────────────────────────────────
684
+
685
+ class EnhancedStanceClassifier:
686
+ """
687
+ Advanced stance classifier combining enhanced semantic rules, improved NLI,
688
+ and better keyword matching with confidence-based decision making.
689
+ """
690
+
691
+ def __init__(self):
692
+ self.semantic_layer = EnhancedSemanticLayer()
693
+ self.keyword_layer = EnhancedKeywordLayer()
694
+ self.nli_layer = EnhancedNLILayer()
695
+ self.text_processor = TextProcessor()
696
+
697
+ # Confidence thresholds for each layer
698
+ self.semantic_threshold = 0.7
699
+ self.keyword_threshold = 0.6
700
+ self.nli_threshold = 0.5
701
+
702
+ # Minimum source reliability for NLI layer
703
+ self.nli_min_reliability = float(os.getenv("STANCE_NLI_MIN_REL", "0.3"))
704
+
705
+ def classify(self, text: str, claim: str) -> str:
706
+ """
707
+ Backward-compatible interface for simple stance classification.
708
+
709
+ Returns:
710
+ stance string: "supporting" | "contradicting" | "neutral"
711
+ """
712
+ stance, _, _ = self.classify_detailed(
713
+ title="", text=text, claim=claim, source_reliability=0.5
714
+ )
715
+ return stance
716
+
717
+ def classify_detailed(
718
+ self,
719
+ title: str,
720
+ text: str,
721
+ claim: str,
722
+ source_reliability: float = 0.5,
723
+ ) -> Tuple[str, float, str]:
724
+ """
725
+ Enhanced stance classification with detailed output.
726
+
727
+ Args:
728
+ title: Source title
729
+ text: Source text content
730
+ claim: Claim to verify against
731
+ source_reliability: Reliability score of the source (0.0-1.0)
732
+
733
+ Returns:
734
+ (stance, confidence, winning_layer)
735
+ """
736
+ # Clean and preprocess text
737
+ combined_text = self.text_processor.clean_text(f"{title} {text}")
738
+ claim_type = self.semantic_layer.classify_claim_type(claim)
739
+
740
+ # Layer 1: Enhanced Semantic Heuristics
741
+ semantic_result = self.semantic_layer.classify(claim, combined_text)
742
+ if semantic_result and semantic_result[1] >= self.semantic_threshold:
743
+ stance, confidence = semantic_result
744
+ logger.debug("Enhanced semantic layer: %s (%.2f)", stance, confidence)
745
+ return stance, confidence, "semantic"
746
+
747
+ # Layer 2: Enhanced Keyword Matching
748
+ keyword_stance, keyword_conf = self.keyword_layer.classify(combined_text, claim)
749
+ if keyword_conf >= self.keyword_threshold:
750
+ logger.debug("Enhanced keyword layer: %s (%.2f)", keyword_stance, keyword_conf)
751
+ return keyword_stance, keyword_conf, "keyword"
752
+
753
+ # Layer 3: Enhanced NLI (only for reliable sources)
754
+ if (self.nli_layer.available() and
755
+ source_reliability >= self.nli_min_reliability):
756
+
757
+ nli_result = self.nli_layer.classify(claim, title, text, claim_type)
758
+ if nli_result and nli_result[1] >= self.nli_threshold:
759
+ stance, confidence = nli_result
760
+ logger.debug("Enhanced NLI layer: %s (%.2f)", stance, confidence)
761
+ return stance, confidence, "nli"
762
+
763
+ # Fallback: Use best available result with adjusted confidence
764
+ best_result = None
765
+ best_confidence = 0.0
766
+ best_layer = "fallback"
767
+
768
+ if semantic_result and semantic_result[1] > best_confidence:
769
+ best_result = semantic_result
770
+ best_confidence = semantic_result[1]
771
+ best_layer = "semantic_fallback"
772
+
773
+ if keyword_conf > best_confidence:
774
+ best_result = (keyword_stance, keyword_conf)
775
+ best_confidence = keyword_conf
776
+ best_layer = "keyword_fallback"
777
+
778
+ if best_result:
779
+ stance, confidence = best_result
780
+ # Reduce confidence for fallback results
781
+ adjusted_confidence = confidence * 0.7
782
+ logger.debug("Fallback layer (%s): %s (%.2f -> %.2f)",
783
+ best_layer, stance, confidence, adjusted_confidence)
784
+ return stance, adjusted_confidence, best_layer
785
+
786
+ # Ultimate fallback
787
+ return "neutral", 0.1, "default"
788
+
789
+ # ─────────────────────────────────────────────────────────────────────────────
790
+ # Factory function for backward compatibility
791
+ # ─────────────────────────────────────────────────────────────────────────────
792
+
793
+ def build_enhanced_stance_classifier(scorer=None) -> EnhancedStanceClassifier:
794
+ """Factory function to create enhanced stance classifier."""
795
+ return EnhancedStanceClassifier()
796
+
797
+ # Alias for easy import
798
+ StanceClassifier = EnhancedStanceClassifier
environment.py ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ environment.py
3
+ ==============
4
+ OpenEnv-compliant InformationIntegrityEnv.
5
+
6
+ The environment is 100% offline and deterministic once a scenario has been
7
+ collected by data_collector.py. It accepts any scenario dict produced by
8
+ DataCollector.collect() β€” no hardcoded content.
9
+
10
+ OpenEnv contract
11
+ ----------------
12
+ env = InformationIntegrityEnv(scenario)
13
+ obs = env.reset()
14
+ obs, rew, done, info = env.step(action)
15
+ state_dict = env.state()
16
+
17
+ Action space
18
+ ------------
19
+ verify_claim claim_id β†’ VerificationResult
20
+ check_source source_id β†’ SourceEvidence
21
+ decide decision (allow/flag/remove) β†’ episode ends
22
+
23
+ Observation
24
+ -----------
25
+ content, claims (status masked), available_sources,
26
+ interaction_history, status_flags, step_count
27
+
28
+ Design invariants
29
+ -----------------
30
+ - Ground truth NEVER exposed in Observation
31
+ - All randomness eliminated (same action sequence β†’ same result)
32
+ - Reward computed by reward.py (configurable, not hardcoded here)
33
+ - Scenario loaded once at __init__; reset() clears episode state only
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import copy
39
+ import os
40
+ import time
41
+ import uuid
42
+ from typing import Any, Dict, List, Optional, Tuple
43
+
44
+ import yaml
45
+
46
+ from models import (
47
+ Action,
48
+ ActionType,
49
+ Claim,
50
+ DecisionType,
51
+ EpisodeResult,
52
+ InteractionRecord,
53
+ Observation,
54
+ Reward,
55
+ RewardBreakdown,
56
+ Scenario,
57
+ ScenarioClaim,
58
+ ScenarioSource,
59
+ SourceInfo,
60
+ Stance,
61
+ StatusFlags,
62
+ VerificationStatus,
63
+ )
64
+ from reward import EpisodeRewardState, RewardFunction
65
+
66
+ try:
67
+ from retrieval import RetrievalPipeline
68
+ from reasoning import DeterministicReasoningEngine, reason_over_claims
69
+ _RETRIEVAL_AVAILABLE = True
70
+ except Exception:
71
+ _RETRIEVAL_AVAILABLE = False
72
+
73
+ RELIABILITY_CONFIG_PATH = os.getenv("RELIABILITY_CONFIG", "domain_reliability.yaml")
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Helpers
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def _load_evidence_thresholds() -> dict:
81
+ if os.path.exists(RELIABILITY_CONFIG_PATH):
82
+ with open(RELIABILITY_CONFIG_PATH, "r", encoding="utf-8") as fh:
83
+ cfg = yaml.safe_load(fh) or {}
84
+ return cfg.get("evidence_thresholds", {})
85
+ return {}
86
+
87
+
88
+ def _scenario_from_dict(d: dict) -> Scenario:
89
+ """Deserialise a raw scenario dict (e.g. from DataCollector) into a Scenario model."""
90
+ claims = [ScenarioClaim(**c) for c in d["claims"]]
91
+ sources = [ScenarioSource(**s) for s in d["sources"]]
92
+ return Scenario(
93
+ id=d.get("id", str(uuid.uuid4())),
94
+ difficulty=d.get("difficulty", "medium"),
95
+ content=d["content"],
96
+ claims=claims,
97
+ sources=sources,
98
+ ground_truth=d["ground_truth"],
99
+ topic=d.get("topic", ""),
100
+ tags=d.get("tags", []),
101
+ explanation_hint=d.get("explanation_hint", ""),
102
+ )
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Main environment class
107
+ # ---------------------------------------------------------------------------
108
+
109
+ class InformationIntegrityEnv:
110
+ """
111
+ OpenEnv environment for reasoning-based content moderation.
112
+
113
+ Parameters
114
+ ----------
115
+ scenario : dict | Scenario
116
+ A collected scenario (from DataCollector) or a pre-built Scenario object.
117
+ max_steps : int
118
+ Maximum steps per episode (default 20).
119
+ reward_fn : RewardFunction | None
120
+ Pluggable reward function (default: RewardFunction()).
121
+ """
122
+
123
+ # Public metadata (read by openenv validate)
124
+ ENV_NAME = "InformationIntegrityEnv"
125
+ ENV_VERSION = "1.0.0"
126
+ ENV_AUTHOR = "openenv-content-moderation"
127
+
128
+ def __init__(
129
+ self,
130
+ scenario: dict | Scenario,
131
+ max_steps: int = 20,
132
+ reward_fn: Optional[RewardFunction] = None,
133
+ ) -> None:
134
+ if isinstance(scenario, dict):
135
+ scenario = _scenario_from_dict(scenario)
136
+ self._scenario : Scenario = scenario
137
+ self._max_steps : int = max_steps
138
+ self._reward_fn : RewardFunction = reward_fn or RewardFunction()
139
+ self._thresholds : dict = _load_evidence_thresholds()
140
+
141
+ # Episode state (initialised by reset)
142
+ self._episode_id : str = ""
143
+ self._claims : List[Claim] = []
144
+ self._history : List[InteractionRecord] = []
145
+ self._flags : StatusFlags = StatusFlags()
146
+ self._step_count : int = 0
147
+ self._done : bool = False
148
+ self._final_decision: Optional[str] = None
149
+ self._reward_state: EpisodeRewardState = EpisodeRewardState()
150
+
151
+ # Index for fast lookup
152
+ self._source_index : Dict[str, ScenarioSource] = {
153
+ s.id: s for s in self._scenario.sources
154
+ }
155
+ self._claim_index : Dict[str, ScenarioClaim] = {
156
+ c.id: c for c in self._scenario.claims
157
+ }
158
+
159
+ # Retrieval + reasoning engines (lazy init for pickle compat)
160
+ self._retrieval_pipeline: Optional[Any] = None
161
+ self._reasoning_engine: Optional[Any] = None
162
+ self._last_reasoning: Optional[Any] = None # StructuredReasoning cached
163
+
164
+ # ------------------------------------------------------------------
165
+ # OpenEnv API
166
+ # ------------------------------------------------------------------
167
+
168
+ def reset(
169
+ self,
170
+ difficulty: Optional[str] = None,
171
+ scenario_id: Optional[str | int] = None,
172
+ ) -> Observation:
173
+ """
174
+ Reset episode state and return initial observation.
175
+
176
+ Programmatic difficulty control (no UI dependency):
177
+
178
+ Parameters
179
+ ----------
180
+ difficulty : str | None
181
+ If provided, load a new scenario from the deterministic dataset
182
+ for that difficulty tier ('easy', 'medium', 'hard').
183
+ Overrides the scenario supplied at __init__ time.
184
+ scenario_id : str | int | None
185
+ Index (int) or scenario id (str) within the difficulty dataset.
186
+ If None and difficulty is provided, a random scenario is selected.
187
+ Ignored when difficulty is None.
188
+ """
189
+ if difficulty is not None:
190
+ from tasks.registry import TASKS
191
+ import random as _random
192
+ task = TASKS[difficulty]
193
+ if scenario_id is None:
194
+ sc_dict = task.sample()
195
+ else:
196
+ sc_dict = task.get_scenario(scenario_id)
197
+ self._scenario = _scenario_from_dict(sc_dict)
198
+ self._source_index = {s.id: s for s in self._scenario.sources}
199
+ self._claim_index = {c.id: c for c in self._scenario.claims}
200
+
201
+ self._episode_id = str(uuid.uuid4())
202
+ self._step_count = 0
203
+ self._done = False
204
+ self._final_decision = None
205
+ self._history = []
206
+ self._flags = StatusFlags()
207
+ self._reward_state = EpisodeRewardState()
208
+ self._last_reasoning = None
209
+
210
+ # Reinitialise mutable claim states (no ground-truth exposed)
211
+ self._claims = [
212
+ Claim(id=c.id, text=c.text)
213
+ for c in self._scenario.claims
214
+ ]
215
+
216
+ return self._build_observation()
217
+
218
+ def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
219
+ """
220
+ Process one agent action.
221
+
222
+ Returns
223
+ -------
224
+ observation : Observation
225
+ reward : Reward
226
+ done : bool
227
+ info : dict (extra diagnostics β€” not for agent use)
228
+ """
229
+ if self._done:
230
+ raise RuntimeError(
231
+ "Episode has ended. Call reset() to start a new episode."
232
+ )
233
+
234
+ self._step_count += 1
235
+ self._reward_state.step_count = self._step_count
236
+
237
+ if action.type == ActionType.VERIFY_CLAIM:
238
+ step_reward, result = self._handle_verify_claim(action)
239
+ elif action.type == ActionType.CHECK_SOURCE:
240
+ step_reward, result = self._handle_check_source(action)
241
+ elif action.type == ActionType.DECIDE:
242
+ step_reward, result = self._handle_decide(action)
243
+ else:
244
+ raise ValueError(f"Unknown action type: {action.type}")
245
+
246
+ # Max-steps check
247
+ if self._step_count >= self._max_steps and not self._done:
248
+ self._done = True
249
+ self._flags.max_steps_reached = True
250
+ result["max_steps_reached"] = True
251
+
252
+ # Update evidence sufficiency flag
253
+ self._flags.sufficient_evidence = self._check_sufficient_evidence()
254
+
255
+ # Log interaction
256
+ record = InteractionRecord(
257
+ step=self._step_count,
258
+ action_type=action.type,
259
+ action_payload=action.model_dump(exclude_none=True),
260
+ result=result,
261
+ reward_delta=step_reward,
262
+ )
263
+ self._history.append(record)
264
+
265
+ obs = self._build_observation()
266
+ reward = self._build_reward(step_reward, result.get("message", ""))
267
+ info = {
268
+ "episode_id": self._episode_id,
269
+ "step": self._step_count,
270
+ "done": self._done,
271
+ **result,
272
+ }
273
+ return obs, reward, self._done, info
274
+
275
+ def state(self) -> Dict[str, Any]:
276
+ """Return full internal state (for debugging / logging β€” not for agent)."""
277
+ return {
278
+ "episode_id": self._episode_id,
279
+ "scenario_id": self._scenario.id,
280
+ "difficulty": self._scenario.difficulty,
281
+ "step_count": self._step_count,
282
+ "done": self._done,
283
+ "final_decision": self._final_decision,
284
+ "ground_truth": self._scenario.ground_truth, # hidden from agent obs
285
+ "claims": [
286
+ {
287
+ "id": c.id,
288
+ "text": c.text,
289
+ "verification_status": c.verification_status,
290
+ "confidence": c.confidence,
291
+ }
292
+ for c in self._claims
293
+ ],
294
+ "reward_breakdown": self._reward_fn.breakdown_dict(self._reward_state),
295
+ "flags": self._flags.model_dump(),
296
+ "history_len": len(self._history),
297
+ "last_reasoning": (
298
+ {ct: r.to_dict() for ct, r in self._last_reasoning.items()}
299
+ if self._last_reasoning else None
300
+ ),
301
+ }
302
+
303
+ def episode_result(self, explanation: Optional[str] = None) -> EpisodeResult:
304
+ """Build a final EpisodeResult (call after done=True)."""
305
+ bd = self._reward_fn.breakdown_dict(self._reward_state)
306
+ ground_truth = self._scenario.ground_truth
307
+ correct = self._final_decision == ground_truth
308
+
309
+ from tasks.graders import GraderFactory
310
+ grader = GraderFactory.get(self._scenario.difficulty)
311
+ grader_score = grader.score(
312
+ decision=self._final_decision or "none",
313
+ ground_truth=ground_truth,
314
+ sources_checked=list(self._reward_state.checked_sources),
315
+ claims_verified=list(self._reward_state.verified_claims),
316
+ step_count=self._step_count,
317
+ all_source_ids=[s.id for s in self._scenario.sources],
318
+ all_claim_ids=[c.id for c in self._scenario.claims],
319
+ )
320
+
321
+ return EpisodeResult(
322
+ episode_id=self._episode_id,
323
+ task_difficulty=self._scenario.difficulty,
324
+ scenario_index=0,
325
+ total_reward=self._reward_fn.final_score(self._reward_state),
326
+ decision=self._final_decision,
327
+ ground_truth=ground_truth,
328
+ correct=correct,
329
+ steps_taken=self._step_count,
330
+ claims_verified=len(self._reward_state.verified_claims),
331
+ sources_checked=len(self._reward_state.checked_sources),
332
+ grader_score=grader_score,
333
+ reward_breakdown=RewardBreakdown(
334
+ verify_claim_reward=bd["verify_claim_reward"],
335
+ check_source_reward=bd["check_source_reward"],
336
+ multi_source_bonus=bd["multi_source_bonus"],
337
+ decision_reward=bd["decision_reward"],
338
+ premature_penalty=bd["premature_penalty"],
339
+ redundant_penalty=bd["redundant_penalty"],
340
+ inefficiency_penalty=bd["inefficiency_penalty"],
341
+ aggression_penalty=bd["aggression_penalty"],
342
+ ),
343
+ explanation=explanation,
344
+ )
345
+
346
+ # ------------------------------------------------------------------
347
+ # Action handlers
348
+ # ------------------------------------------------------------------
349
+
350
+ def _handle_verify_claim(self, action: Action) -> Tuple[float, dict]:
351
+ claim_id = action.claim_id
352
+ if claim_id not in self._claim_index:
353
+ return 0.0, {"error": f"Unknown claim_id: {claim_id}"}
354
+
355
+ scenario_claim = self._claim_index[claim_id]
356
+ agent_claim = self._get_agent_claim(claim_id)
357
+
358
+ # Reveal verification status from scenario ground truth (masked from Observation)
359
+ status = scenario_claim.ground_truth_status
360
+ confidence = scenario_claim.ground_truth_confidence
361
+
362
+ # Use RetrievalPipeline to find the most relevant sources for THIS claim
363
+ if _RETRIEVAL_AVAILABLE:
364
+ if self._retrieval_pipeline is None:
365
+ self._retrieval_pipeline = RetrievalPipeline()
366
+ raw_sources = [
367
+ {
368
+ "id": s.id,
369
+ "title": s.title,
370
+ "text": s.text,
371
+ "url": s.url,
372
+ "stance": s.stance,
373
+ "reliability": s.reliability,
374
+ "source_type": s.source_type,
375
+ }
376
+ for s in self._scenario.sources
377
+ ]
378
+ ranked = self._retrieval_pipeline.retrieve(scenario_claim.text, raw_sources)
379
+ relevant_sources = [s.id for s in ranked[:5]]
380
+ else:
381
+ relevant_sources = [
382
+ s.id for s in self._scenario.sources
383
+ if s.stance in ("contradicting", "supporting")
384
+ ]
385
+
386
+ # Update mutable claim object
387
+ agent_claim.verification_status = VerificationStatus(status)
388
+ agent_claim.confidence = confidence
389
+ agent_claim.verified_by_sources = relevant_sources[:3]
390
+
391
+ # Reward
392
+ r, msg = self._reward_fn.compute_verify_claim(claim_id, self._reward_state)
393
+
394
+ result: dict = {
395
+ "claim_id": claim_id,
396
+ "status": status,
397
+ "confidence": confidence,
398
+ "message": msg,
399
+ "relevant_sources": relevant_sources,
400
+ "hint": f"Claim '{scenario_claim.text[:60]}…' assessed as {status}",
401
+ }
402
+ return r, result
403
+
404
+ def _handle_check_source(self, action: Action) -> Tuple[float, dict]:
405
+ source_id = action.source_id
406
+ if source_id not in self._source_index:
407
+ return 0.0, {"error": f"Unknown source_id: {source_id}"}
408
+
409
+ src = self._source_index[source_id]
410
+
411
+ # Reward
412
+ r, msg = self._reward_fn.compute_check_source(source_id, self._reward_state)
413
+
414
+ result: dict = {
415
+ "source_id": source_id,
416
+ "title": src.title,
417
+ "text": src.text,
418
+ "url": src.url,
419
+ "stance": src.stance,
420
+ "reliability": src.reliability,
421
+ "source_type": src.source_type,
422
+ "message": msg,
423
+ }
424
+ return r, result
425
+
426
+ def _handle_decide(self, action: Action) -> Tuple[float, dict]:
427
+ decision = action.decision
428
+ ground_truth = self._scenario.ground_truth
429
+ num_claims = len(self._scenario.claims)
430
+
431
+ # Run deterministic reasoning engine if available
432
+ reasoning_summary = {}
433
+ if _RETRIEVAL_AVAILABLE:
434
+ if self._retrieval_pipeline is None:
435
+ self._retrieval_pipeline = RetrievalPipeline()
436
+ if self._reasoning_engine is None:
437
+ self._reasoning_engine = DeterministicReasoningEngine()
438
+
439
+ raw_sources = [
440
+ {
441
+ "id": s.id,
442
+ "title": s.title,
443
+ "text": s.text,
444
+ "url": s.url,
445
+ "stance": s.stance,
446
+ "reliability": s.reliability,
447
+ "source_type": s.source_type,
448
+ }
449
+ for s in self._scenario.sources
450
+ ]
451
+ claim_texts = [c.text for c in self._scenario.claims]
452
+ claim_results = self._retrieval_pipeline.retrieve_for_all_claims(
453
+ claim_texts, raw_sources
454
+ )
455
+ agg_decision, per_claim_reasoning = reason_over_claims(
456
+ claim_results, self._reasoning_engine
457
+ )
458
+ # Cache reasoning for episode_result()
459
+ self._last_reasoning = per_claim_reasoning
460
+ reasoning_summary = {
461
+ "engine_decision": agg_decision,
462
+ "agent_decision": decision,
463
+ "per_claim": {
464
+ ct: r.to_dict() for ct, r in per_claim_reasoning.items()
465
+ },
466
+ }
467
+
468
+ r, msg = self._reward_fn.compute_decision(
469
+ decision=decision,
470
+ ground_truth=ground_truth,
471
+ state=self._reward_state,
472
+ num_claims=num_claims,
473
+ )
474
+
475
+ self._final_decision = decision
476
+ self._done = True
477
+ self._flags.decision_made = True
478
+
479
+ if "premature" in msg:
480
+ self._flags.premature_decision = True
481
+
482
+ result: dict = {
483
+ "decision": decision,
484
+ "message": msg,
485
+ "episode_done": True,
486
+ "reasoning": reasoning_summary,
487
+ }
488
+ return r, result
489
+
490
+ # ------------------------------------------------------------------
491
+ # State construction helpers
492
+ # ------------------------------------------------------------------
493
+
494
+ def _build_observation(self) -> Observation:
495
+ """Build agent-facing Observation β€” ground truth is NEVER included."""
496
+ sources_view = [
497
+ SourceInfo(
498
+ id=s.id,
499
+ title=s.title,
500
+ text=s.text,
501
+ url=s.url,
502
+ stance=Stance(s.stance),
503
+ reliability=s.reliability,
504
+ source_type=s.source_type,
505
+ )
506
+ for s in self._scenario.sources
507
+ ]
508
+ return Observation(
509
+ content=self._scenario.content,
510
+ claims=copy.deepcopy(self._claims),
511
+ available_sources=sources_view,
512
+ interaction_history=list(self._history),
513
+ status_flags=copy.deepcopy(self._flags),
514
+ step_count=self._step_count,
515
+ episode_id=self._episode_id,
516
+ task_difficulty=self._scenario.difficulty,
517
+ )
518
+
519
+ def _build_reward(self, score: float, message: str) -> Reward:
520
+ bd = self._reward_fn.breakdown_dict(self._reward_state)
521
+ return Reward(
522
+ score=score,
523
+ breakdown=RewardBreakdown(
524
+ verify_claim_reward=bd["verify_claim_reward"],
525
+ check_source_reward=bd["check_source_reward"],
526
+ multi_source_bonus=bd["multi_source_bonus"],
527
+ decision_reward=bd["decision_reward"],
528
+ premature_penalty=bd["premature_penalty"],
529
+ redundant_penalty=bd["redundant_penalty"],
530
+ inefficiency_penalty=bd["inefficiency_penalty"],
531
+ aggression_penalty=bd["aggression_penalty"],
532
+ ),
533
+ message=message,
534
+ is_terminal=self._done,
535
+ )
536
+
537
+ def _get_agent_claim(self, claim_id: str) -> Claim:
538
+ for c in self._claims:
539
+ if c.id == claim_id:
540
+ return c
541
+ raise KeyError(f"Claim {claim_id} not found in agent state")
542
+
543
+ def _check_sufficient_evidence(self) -> bool:
544
+ thresh = self._thresholds
545
+ min_sources = int(thresh.get("min_sources_for_decision", 2))
546
+ min_verified = float(thresh.get("min_claims_verified_fraction", 0.5))
547
+ n_claims = len(self._scenario.claims)
548
+ n_verified = len(self._reward_state.verified_claims)
549
+ n_sources = len(self._reward_state.checked_sources)
550
+
551
+ frac_ok = (n_verified / n_claims >= min_verified) if n_claims > 0 else True
552
+ return n_sources >= min_sources and frac_ok
553
+
554
+ # ------------------------------------------------------------------
555
+ # Convenience helpers
556
+ # ------------------------------------------------------------------
557
+
558
+ @property
559
+ def scenario(self) -> Scenario:
560
+ return self._scenario
561
+
562
+ @property
563
+ def max_steps(self) -> int:
564
+ return self._max_steps
565
+
566
+ @property
567
+ def done(self) -> bool:
568
+ return self._done
569
+
570
+ @classmethod
571
+ def from_dataset(
572
+ cls,
573
+ difficulty: str,
574
+ scenario_id: Optional[str | int] = None,
575
+ max_steps: int = 20,
576
+ reward_fn: Optional[RewardFunction] = None,
577
+ ) -> "InformationIntegrityEnv":
578
+ """
579
+ Build an env directly from the deterministic dataset β€” no DataCollector needed.
580
+
581
+ Parameters
582
+ ----------
583
+ difficulty : 'easy' | 'medium' | 'hard'
584
+ scenario_id : int index or str id; None = random selection
585
+ max_steps : episode step limit
586
+ reward_fn : optional custom RewardFunction
587
+
588
+ Example
589
+ -------
590
+ env = InformationIntegrityEnv.from_dataset("medium", scenario_id=2)
591
+ obs = env.reset()
592
+ """
593
+ from tasks.registry import TASKS
594
+ task = TASKS[difficulty]
595
+ if scenario_id is None:
596
+ sc_dict = task.sample()
597
+ else:
598
+ sc_dict = task.get_scenario(scenario_id)
599
+ return cls(scenario=sc_dict, max_steps=max_steps, reward_fn=reward_fn)
600
+
601
+ @classmethod
602
+ def from_collector(
603
+ cls,
604
+ content: str,
605
+ claims: List[str],
606
+ difficulty: str = "medium",
607
+ **collector_kwargs,
608
+ ) -> "InformationIntegrityEnv":
609
+ """
610
+ Convenience factory: collect a scenario on-the-fly and build an env.
611
+
612
+ Example
613
+ -------
614
+ env = InformationIntegrityEnv.from_collector(
615
+ content="Post text here",
616
+ claims=["Claim A", "Claim B"],
617
+ difficulty="hard",
618
+ )
619
+ obs = env.reset()
620
+ """
621
+ from data_collector import CollectionRequest, DataCollector
622
+
623
+ collector = DataCollector()
624
+ request = CollectionRequest(
625
+ content=content,
626
+ claims=claims,
627
+ difficulty=difficulty,
628
+ **collector_kwargs,
629
+ )
630
+ scenario_dict = collector.collect(request)
631
+ return cls(scenario=scenario_dict)
explainability.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ explainability.py
3
+ =================
4
+ Structured reasoning objects and template-based explanation generation.
5
+
6
+ No API calls β€” fully deterministic. The explanation is built from the
7
+ agent's actual reasoning trace (claims verified, sources checked, decision)
8
+ so it aligns with every moderation decision and is auditable.
9
+
10
+ Enterprise platforms (Meta, Instagram, etc.) can:
11
+ - Replace `generate_explanation` with a call to their internal LLM
12
+ - Feed `ReasoningObject` into dashboards / audit logs
13
+ - Extend `DECISION_TEMPLATES` without touching engine code
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import textwrap
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ import yaml
24
+
25
+ EXPLANATION_CONFIG_PATH = os.getenv("EXPLANATION_CONFIG", "explanation_templates.yaml")
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Structured reasoning object (what the agent built up during the episode)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ @dataclass
33
+ class SourceEvidence:
34
+ source_id: str
35
+ title: str
36
+ stance: str # supporting | contradicting | neutral
37
+ reliability: float
38
+ key_snippet: str = ""
39
+
40
+
41
+ @dataclass
42
+ class ClaimAssessment:
43
+ claim_id: str
44
+ claim_text: str
45
+ verification_status: str # true | false | partially_true | unknown
46
+ confidence: float
47
+ supporting_sources: List[str] = field(default_factory=list)
48
+ contradicting_sources: List[str] = field(default_factory=list)
49
+
50
+
51
+ @dataclass
52
+ class ReasoningObject:
53
+ """
54
+ Complete structured reasoning trace for one episode.
55
+ Produced by the agent or inference script; consumed by the explainer.
56
+ """
57
+ content_summary: str
58
+ claims: List[ClaimAssessment]
59
+ sources_used: List[SourceEvidence]
60
+ decision: str # allow | flag | remove
61
+ confidence: float # 0–1
62
+ step_count: int
63
+ episode_id: str = ""
64
+ difficulty: str = ""
65
+ extra: Dict[str, Any] = field(default_factory=dict)
66
+
67
+ # Derived helpers
68
+ @property
69
+ def contradicting_sources(self) -> List[SourceEvidence]:
70
+ return [s for s in self.sources_used if s.stance == "contradicting"]
71
+
72
+ @property
73
+ def supporting_sources(self) -> List[SourceEvidence]:
74
+ return [s for s in self.sources_used if s.stance == "supporting"]
75
+
76
+ @property
77
+ def high_reliability_sources(self) -> List[SourceEvidence]:
78
+ return [s for s in self.sources_used if s.reliability >= 0.80]
79
+
80
+ @property
81
+ def false_claims(self) -> List[ClaimAssessment]:
82
+ return [c for c in self.claims if c.verification_status in ("false", "partially_true")]
83
+
84
+ @property
85
+ def verified_fraction(self) -> float:
86
+ if not self.claims:
87
+ return 0.0
88
+ verified = sum(
89
+ 1 for c in self.claims
90
+ if c.verification_status != "unknown"
91
+ )
92
+ return verified / len(self.claims)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Template engine
97
+ # ---------------------------------------------------------------------------
98
+
99
+ # Built-in templates β€” can be fully overridden by explanation_templates.yaml
100
+ _BUILTIN_TEMPLATES: Dict[str, str] = {
101
+
102
+ "remove": textwrap.dedent("""\
103
+ MODERATION DECISION: REMOVE
104
+
105
+ Content summary: {content_summary}
106
+
107
+ After examining {n_claims} claim(s) across {n_sources} source(s):
108
+
109
+ {claim_assessments}
110
+
111
+ KEY EVIDENCE
112
+ {evidence_list}
113
+
114
+ REASONING
115
+ {reasoning_narrative}
116
+
117
+ CONCLUSION
118
+ The content was removed because {removal_reason}.
119
+ Confidence: {confidence_pct}%. Sources consulted: {n_sources}.
120
+ """),
121
+
122
+ "flag": textwrap.dedent("""\
123
+ MODERATION DECISION: FLAG FOR REVIEW
124
+
125
+ Content summary: {content_summary}
126
+
127
+ After examining {n_claims} claim(s) across {n_sources} source(s):
128
+
129
+ {claim_assessments}
130
+
131
+ KEY EVIDENCE
132
+ {evidence_list}
133
+
134
+ REASONING
135
+ {reasoning_narrative}
136
+
137
+ CONCLUSION
138
+ The content was flagged because {flag_reason}.
139
+ Confidence: {confidence_pct}%. Sources consulted: {n_sources}.
140
+ """),
141
+
142
+ "allow": textwrap.dedent("""\
143
+ MODERATION DECISION: ALLOW
144
+
145
+ Content summary: {content_summary}
146
+
147
+ After examining {n_claims} claim(s) across {n_sources} source(s):
148
+
149
+ {claim_assessments}
150
+
151
+ KEY EVIDENCE
152
+ {evidence_list}
153
+
154
+ REASONING
155
+ {reasoning_narrative}
156
+
157
+ CONCLUSION
158
+ The content was allowed because {allow_reason}.
159
+ Confidence: {confidence_pct}%. Sources consulted: {n_sources}.
160
+ """),
161
+ }
162
+
163
+
164
+ class ExplanationTemplates:
165
+ """Loads templates from YAML; falls back to built-ins."""
166
+
167
+ def __init__(self, path: str = EXPLANATION_CONFIG_PATH) -> None:
168
+ self._templates = dict(_BUILTIN_TEMPLATES)
169
+ if os.path.exists(path):
170
+ with open(path, "r", encoding="utf-8") as fh:
171
+ loaded = yaml.safe_load(fh) or {}
172
+ self._templates.update(loaded.get("templates", {}))
173
+
174
+ def get(self, decision: str) -> str:
175
+ return self._templates.get(decision, self._templates["flag"])
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Explainability engine
180
+ # ---------------------------------------------------------------------------
181
+
182
+ class ExplainabilityEngine:
183
+ """
184
+ Converts a ReasoningObject into a structured, human-readable explanation.
185
+
186
+ No ML or API calls. All text is derived deterministically from the
187
+ agent's actual evidence trace.
188
+ """
189
+
190
+ def __init__(self, templates: Optional[ExplanationTemplates] = None) -> None:
191
+ self._tpl = templates or ExplanationTemplates()
192
+
193
+ def generate_explanation(self, reasoning: ReasoningObject) -> str:
194
+ """Return a formatted explanation string."""
195
+ template = self._tpl.get(reasoning.decision)
196
+ variables = self._build_variables(reasoning)
197
+ try:
198
+ return template.format(**variables)
199
+ except KeyError as exc:
200
+ # Graceful fallback β€” missing template var
201
+ return self._fallback(reasoning) + f"\n[template key missing: {exc}]"
202
+
203
+ def generate_structured(self, reasoning: ReasoningObject) -> dict:
204
+ """Return a machine-readable structured explanation for API responses."""
205
+ return {
206
+ "decision": reasoning.decision,
207
+ "confidence": round(reasoning.confidence, 3),
208
+ "content_summary": reasoning.content_summary,
209
+ "claims_assessed": [
210
+ {
211
+ "id": c.claim_id,
212
+ "text": c.claim_text,
213
+ "status": c.verification_status,
214
+ "conf": round(c.confidence, 3),
215
+ }
216
+ for c in reasoning.claims
217
+ ],
218
+ "sources_used": [
219
+ {
220
+ "id": s.source_id,
221
+ "title": s.title,
222
+ "stance": s.stance,
223
+ "reliability": round(s.reliability, 3),
224
+ }
225
+ for s in reasoning.sources_used
226
+ ],
227
+ "evidence_summary": {
228
+ "contradicting": len(reasoning.contradicting_sources),
229
+ "supporting": len(reasoning.supporting_sources),
230
+ "high_reliability": len(reasoning.high_reliability_sources),
231
+ },
232
+ "narrative": self.generate_explanation(reasoning),
233
+ "steps_taken": reasoning.step_count,
234
+ "episode_id": reasoning.episode_id,
235
+ }
236
+
237
+ # ------------------------------------------------------------------
238
+ # Template variable builder
239
+ # ------------------------------------------------------------------
240
+
241
+ def _build_variables(self, r: ReasoningObject) -> dict:
242
+ return {
243
+ "content_summary": r.content_summary or "(no summary)",
244
+ "n_claims": len(r.claims),
245
+ "n_sources": len(r.sources_used),
246
+ "confidence_pct": int(r.confidence * 100),
247
+ "claim_assessments": self._format_claims(r.claims),
248
+ "evidence_list": self._format_evidence(r.sources_used),
249
+ "reasoning_narrative":self._narrative(r),
250
+ "removal_reason": self._removal_reason(r),
251
+ "flag_reason": self._flag_reason(r),
252
+ "allow_reason": self._allow_reason(r),
253
+ }
254
+
255
+ @staticmethod
256
+ def _format_claims(claims: List[ClaimAssessment]) -> str:
257
+ if not claims:
258
+ return " (no claims assessed)"
259
+ lines = []
260
+ for c in claims:
261
+ status_icon = {
262
+ "true": "[TRUE]",
263
+ "false": "[FALSE]",
264
+ "partially_true": "[PARTIAL]",
265
+ "unknown": "[UNKNOWN]",
266
+ "unverified": "[NOT VERIFIED]",
267
+ }.get(c.verification_status, "[?]")
268
+ lines.append(
269
+ f" {status_icon} Claim: {c.claim_text}\n"
270
+ f" Status: {c.verification_status} "
271
+ f"(confidence {int(c.confidence * 100)}%)"
272
+ )
273
+ return "\n".join(lines)
274
+
275
+ @staticmethod
276
+ def _format_evidence(sources: List[SourceEvidence]) -> str:
277
+ if not sources:
278
+ return " (no sources checked)"
279
+ lines = []
280
+ for s in sorted(sources, key=lambda x: -x.reliability):
281
+ icon = {"contradicting": "βœ—", "supporting": "βœ“", "neutral": "Β·"}.get(
282
+ s.stance, "Β·"
283
+ )
284
+ rel_bar = "β–ˆ" * int(s.reliability * 10) + "β–‘" * (10 - int(s.reliability * 10))
285
+ snippet = (s.key_snippet[:100] + "…") if len(s.key_snippet) > 100 else s.key_snippet
286
+ lines.append(
287
+ f" {icon} [{s.stance.upper():<14}] rel={rel_bar} ({s.reliability:.2f})\n"
288
+ f" {s.title}\n"
289
+ + (f" \"{snippet}\"" if snippet else "")
290
+ )
291
+ return "\n".join(lines)
292
+
293
+ def _narrative(self, r: ReasoningObject) -> str:
294
+ parts = []
295
+
296
+ n_false = len(r.false_claims)
297
+ n_total = len(r.claims)
298
+ parts.append(
299
+ f"{n_false} of {n_total} claim(s) were found to be false or partially false."
300
+ )
301
+
302
+ if r.contradicting_sources:
303
+ top = max(r.contradicting_sources, key=lambda s: s.reliability)
304
+ parts.append(
305
+ f"The strongest contradicting source was '{top.title}' "
306
+ f"(reliability {top.reliability:.2f}), which "
307
+ f"directly contradicts the claims made in the content."
308
+ )
309
+
310
+ if r.supporting_sources:
311
+ top = max(r.supporting_sources, key=lambda s: s.reliability)
312
+ parts.append(
313
+ f"One supporting source was found: '{top.title}' "
314
+ f"(reliability {top.reliability:.2f})."
315
+ )
316
+ else:
317
+ parts.append("No credible supporting sources were identified.")
318
+
319
+ if r.high_reliability_sources:
320
+ names = ", ".join(
321
+ f"'{s.title[:40]}'" for s in r.high_reliability_sources[:3]
322
+ )
323
+ parts.append(
324
+ f"High-reliability sources consulted: {names}."
325
+ )
326
+
327
+ parts.append(
328
+ f"The agent took {r.step_count} reasoning step(s) "
329
+ f"and verified {int(r.verified_fraction * 100)}% of claims before deciding."
330
+ )
331
+
332
+ return " " + "\n ".join(parts)
333
+
334
+ @staticmethod
335
+ def _removal_reason(r: ReasoningObject) -> str:
336
+ false_count = len(r.false_claims)
337
+ hi_rel = len(r.high_reliability_sources)
338
+ if false_count:
339
+ claim_texts = "; ".join(
340
+ f"'{c.claim_text[:50]}'" for c in r.false_claims[:2]
341
+ )
342
+ return (
343
+ f"{false_count} claim(s) were definitively false according to "
344
+ f"{hi_rel} high-reliability source(s), including: {claim_texts}"
345
+ )
346
+ return "overwhelming contradicting evidence from authoritative sources was found"
347
+
348
+ @staticmethod
349
+ def _flag_reason(r: ReasoningObject) -> str:
350
+ n_contra = len(r.contradicting_sources)
351
+ n_support = len(r.supporting_sources)
352
+ if n_contra > 0 and n_support > 0:
353
+ return (
354
+ f"evidence was mixed ({n_contra} contradicting, {n_support} supporting), "
355
+ "requiring human review to determine context and intent"
356
+ )
357
+ if n_contra > 0:
358
+ return (
359
+ f"{n_contra} contradicting source(s) were found but evidence is "
360
+ "insufficient for removal; human review is recommended"
361
+ )
362
+ return "the claim could not be fully verified with available sources"
363
+
364
+ @staticmethod
365
+ def _allow_reason(r: ReasoningObject) -> str:
366
+ n_support = len(r.supporting_sources)
367
+ hi_rel = len(r.high_reliability_sources)
368
+ return (
369
+ f"{n_support} supporting source(s) (including {hi_rel} high-reliability) "
370
+ "corroborate the claims and no significant contradicting evidence was found"
371
+ )
372
+
373
+ @staticmethod
374
+ def _fallback(r: ReasoningObject) -> str:
375
+ return (
376
+ f"MODERATION DECISION: {r.decision.upper()}\n"
377
+ f"Content: {r.content_summary}\n"
378
+ f"Claims verified: {len(r.claims)}. Sources checked: {len(r.sources_used)}.\n"
379
+ f"Decision confidence: {int(r.confidence * 100)}%."
380
+ )
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # Factory helper used by inference.py and server.py
385
+ # ---------------------------------------------------------------------------
386
+
387
+ def build_reasoning_from_episode(
388
+ obs, # Final Observation object
389
+ decision: str,
390
+ episode_id: str = "",
391
+ difficulty: str = "",
392
+ ) -> ReasoningObject:
393
+ """
394
+ Convenience builder: converts a final Observation into a ReasoningObject.
395
+ Compatible with any agent that used verify_claim + check_source + decide.
396
+ """
397
+ # Collect checked sources from interaction history
398
+ checked_source_ids = {
399
+ r.action_payload.get("source_id")
400
+ for r in obs.interaction_history
401
+ if r.action_type == "check_source"
402
+ }
403
+
404
+ sources_used = [
405
+ SourceEvidence(
406
+ source_id=s.id,
407
+ title=s.title,
408
+ stance=s.stance if isinstance(s.stance, str) else s.stance.value,
409
+ reliability=s.reliability,
410
+ key_snippet=s.text[:200],
411
+ )
412
+ for s in obs.available_sources
413
+ if s.id in checked_source_ids
414
+ ]
415
+
416
+ claims_assessed = [
417
+ ClaimAssessment(
418
+ claim_id=c.id,
419
+ claim_text=c.text,
420
+ verification_status=(
421
+ c.verification_status
422
+ if isinstance(c.verification_status, str)
423
+ else c.verification_status.value
424
+ ),
425
+ confidence=c.confidence,
426
+ )
427
+ for c in obs.claims
428
+ ]
429
+
430
+ # Confidence = mean reliability of checked sources (or 0.5 fallback)
431
+ confidence = (
432
+ sum(s.reliability for s in sources_used) / len(sources_used)
433
+ if sources_used else 0.5
434
+ )
435
+
436
+ return ReasoningObject(
437
+ content_summary=obs.content[:200],
438
+ claims=claims_assessed,
439
+ sources_used=sources_used,
440
+ decision=decision,
441
+ confidence=confidence,
442
+ step_count=obs.step_count,
443
+ episode_id=episode_id,
444
+ difficulty=difficulty,
445
+ )
inference.py ADDED
@@ -0,0 +1,713 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference.py
3
+ ============
4
+ Baseline ReAct-style agent for InformationIntegrityEnv.
5
+
6
+ Two run modes
7
+ -------------
8
+ benchmark Run all difficulties over all scenarios (default).
9
+ Prints per-episode [START]/[STEP]/[END] lines, then a final
10
+ summary table and tier-separation check.
11
+
12
+ single Run one episode against a specific scenario or a live server.
13
+
14
+ Environment variables
15
+ ---------------------
16
+ API_BASE_URL URL of the LLM provider (OpenAI-compatible)
17
+ MODEL_NAME Model to use (e.g. gpt-4o, claude-3-5-sonnet, etc.)
18
+ HF_TOKEN Bearer token / API key for the provider
19
+ ENV_SERVER_URL URL of the env server (default http://localhost:8000)
20
+ MAX_STEPS Per-episode step limit (default 20)
21
+ BENCHMARK_SEED Random seed for reproducible scenario sampling (default 42)
22
+
23
+ Logging protocol (required by OpenEnv)
24
+ ---------------------------------------
25
+ [START] β€” once at episode start
26
+ [STEP] β€” at every step
27
+ [END] β€” with final score
28
+
29
+ Benchmark summary format (required)
30
+ -------------------------------------
31
+ difficulty=easy avg_score=X.XXXX avg_reward=X.XXXX n=N
32
+ difficulty=medium avg_score=X.XXXX avg_reward=X.XXXX n=N
33
+ difficulty=hard avg_score=X.XXXX avg_reward=X.XXXX n=N
34
+
35
+ Usage
36
+ -----
37
+ # Full benchmark (local, all scenarios)
38
+ python inference.py --benchmark
39
+
40
+ # Single episode from dataset
41
+ python inference.py --local --difficulty medium --scenario-id 0
42
+
43
+ # Single episode from JSON file
44
+ python inference.py --local --scenario scenario.json
45
+
46
+ # Against running env server
47
+ python inference.py --server --difficulty easy --scenario-id 1
48
+
49
+ # Write benchmark results to JSON
50
+ python inference.py --benchmark --out results.json
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import argparse
56
+ import json
57
+ import logging
58
+ import os
59
+ import random
60
+ import sys
61
+ import time
62
+ from typing import Any, Dict, List, Optional
63
+
64
+ try:
65
+ from dotenv import load_dotenv
66
+ load_dotenv()
67
+ except ImportError:
68
+ pass # rely on shell environment
69
+
70
+ import requests as http_requests
71
+ from openai import OpenAI
72
+
73
+ logging.basicConfig(
74
+ level=logging.INFO,
75
+ format="%(asctime)s [%(levelname)s] %(message)s",
76
+ )
77
+ logger = logging.getLogger("inference")
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Config from environment
81
+ # ---------------------------------------------------------------------------
82
+
83
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
84
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
85
+ HF_TOKEN = os.getenv("HF_TOKEN")
86
+ ENV_SERVER_URL = os.getenv("ENV_SERVER_URL", "http://localhost:8000")
87
+ MAX_STEPS = int(os.getenv("MAX_STEPS", 10))
88
+ BENCHMARK_SEED = int(os.getenv("BENCHMARK_SEED", 42))
89
+ DIFFICULTY = os.getenv("DIFFICULTY", "easy")
90
+ SCENARIO_ID = os.getenv("SCENARIO_ID", None) # int index or string scenario id
91
+
92
+ DIFFICULTIES = ["easy", "medium", "hard"]
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # LLM client
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def _build_client() -> OpenAI:
100
+ if not HF_TOKEN:
101
+ raise ValueError(
102
+ "No API key found. Set HF_TOKEN environment variable."
103
+ )
104
+ return OpenAI(api_key=HF_TOKEN, base_url=API_BASE_URL)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Environment interface β€” local (in-process)
109
+ # ---------------------------------------------------------------------------
110
+
111
+ class LocalEnvClient:
112
+ """Runs InformationIntegrityEnv in-process against the static dataset."""
113
+
114
+ def __init__(
115
+ self,
116
+ difficulty: str,
117
+ scenario_id: Optional[str | int] = None,
118
+ scenario_dict: Optional[dict] = None,
119
+ ) -> None:
120
+ from environment import InformationIntegrityEnv
121
+
122
+ if scenario_dict is not None:
123
+ self._env = InformationIntegrityEnv(scenario=scenario_dict)
124
+ else:
125
+ self._env = InformationIntegrityEnv.from_dataset(
126
+ difficulty=difficulty,
127
+ scenario_id=scenario_id,
128
+ )
129
+ self._done = False
130
+
131
+ def reset(self, difficulty: Optional[str] = None, scenario_id: Optional[str | int] = None) -> dict:
132
+ obs = self._env.reset(difficulty=difficulty, scenario_id=scenario_id)
133
+ self._done = False
134
+ return obs.model_dump()
135
+
136
+ def step(self, action: dict) -> dict:
137
+ from models import Action
138
+ act = Action(**action)
139
+ obs, rew, done, info = self._env.step(act)
140
+ self._done = done
141
+ return {
142
+ "observation": obs.model_dump(),
143
+ "reward": rew.model_dump(),
144
+ "done": done,
145
+ "info": info,
146
+ }
147
+
148
+ @property
149
+ def done(self) -> bool:
150
+ return self._done
151
+
152
+ def episode_result(self) -> dict:
153
+ return self._env.episode_result().model_dump()
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Environment interface β€” remote server
158
+ # ---------------------------------------------------------------------------
159
+
160
+ class ServerEnvClient:
161
+ """Communicates with a running FastAPI env server."""
162
+
163
+ def __init__(self, base_url: str = ENV_SERVER_URL) -> None:
164
+ self._base = base_url.rstrip("/")
165
+ self._session_id: Optional[str] = None
166
+
167
+ def reset(
168
+ self,
169
+ difficulty: Optional[str] = None,
170
+ scenario_id: Optional[str | int] = None,
171
+ scenario_dict: Optional[dict] = None,
172
+ ) -> dict:
173
+ payload: dict = {"max_steps": MAX_STEPS}
174
+ if scenario_dict:
175
+ payload["scenario"] = scenario_dict
176
+ elif difficulty:
177
+ payload["difficulty"] = difficulty
178
+ if scenario_id is not None:
179
+ payload["scenario_id"] = scenario_id
180
+ r = http_requests.post(f"{self._base}/reset", json=payload, timeout=30)
181
+ r.raise_for_status()
182
+ data = r.json()
183
+ self._session_id = data["session_id"]
184
+ return data["observation"]
185
+
186
+ def step(self, action: dict) -> dict:
187
+ payload = {"session_id": self._session_id, "action": action}
188
+ r = http_requests.post(f"{self._base}/step", json=payload, timeout=30)
189
+ r.raise_for_status()
190
+ return r.json()
191
+
192
+ def episode_result(self) -> dict:
193
+ # Server does not expose a dedicated episode_result endpoint;
194
+ # grader_score is included in the final step's info dict.
195
+ return {}
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # System prompt
200
+ # ---------------------------------------------------------------------------
201
+
202
+ SYSTEM_PROMPT = SYSTEM_PROMPT = """You are a high-precision content moderation agent.
203
+
204
+ Your goal is to make the MOST ACCURATE moderation decision based on evidence.
205
+
206
+ You MUST follow a structured reasoning process:
207
+
208
+ 1. First verify claims.
209
+ 2. Then evaluate sources carefully:
210
+ - Prefer HIGH reliability sources
211
+ - Consider stance (support/refute/neutral)
212
+ 3. Avoid redundant actions.
213
+ 4. Only decide when evidence is sufficient.
214
+
215
+ Decision guidelines:
216
+ - REMOVE β†’ if content is clearly false and harmful
217
+ - FLAG β†’ if uncertain, conflicting evidence, or partially misleading
218
+ - ALLOW β†’ if claims are well-supported by reliable sources
219
+
220
+ Action rules:
221
+ - You MUST verify at least one claim
222
+ - You MUST check at least two sources
223
+ - NEVER repeat the same action
224
+ - Be efficient (avoid unnecessary steps)
225
+
226
+ Output STRICT JSON ONLY:
227
+ {"type": "...", ...}
228
+ """
229
+
230
+
231
+ def _observation_prompt(obs_dict: dict) -> str:
232
+ content = obs_dict.get("content", "")
233
+ claims = obs_dict.get("claims", [])
234
+ sources = obs_dict.get("available_sources", [])
235
+ history = obs_dict.get("interaction_history", [])
236
+ flags = obs_dict.get("status_flags", {})
237
+ step = obs_dict.get("step_count", 0)
238
+
239
+ # Track what has already been done from full history
240
+ checked_source_ids = {
241
+ h["action_payload"].get("source_id")
242
+ for h in history
243
+ if h.get("action_type") == "check_source" and h.get("action_payload", {}).get("source_id")
244
+ }
245
+ verified_claim_ids = {
246
+ h["action_payload"].get("claim_id")
247
+ for h in history
248
+ if h.get("action_type") == "verify_claim" and h.get("action_payload", {}).get("claim_id")
249
+ }
250
+
251
+ claims_text = "\n".join(
252
+ f" [{c['id']}] {c['text']} β€” status: {c.get('verification_status','unverified')}"
253
+ + (" [ALREADY VERIFIED β€” do not repeat]" if c['id'] in verified_claim_ids else "")
254
+ for c in claims
255
+ )
256
+ sources_text = "\n".join(
257
+ f" [{s['id']}] {s['title']} | stance={s['stance']} | reliability={s['reliability']:.2f}"
258
+ + (" [ALREADY CHECKED β€” do not repeat]" if s['id'] in checked_source_ids else "")
259
+ for s in sources
260
+ )
261
+
262
+ last_result = ""
263
+ if history:
264
+ last = history[-1]
265
+ last_result = f"\nLast step result: {json.dumps(last.get('result', {}), ensure_ascii=False)[:300]}"
266
+
267
+ unchecked_sources = [s['id'] for s in sources if s['id'] not in checked_source_ids]
268
+ unverified_claims = [c['id'] for c in claims if c['id'] not in verified_claim_ids]
269
+ sufficient = flags.get('sufficient_evidence', False)
270
+
271
+ next_step_hint = ""
272
+ if not sufficient:
273
+ if unverified_claims:
274
+ next_step_hint = f"\nHINT: You still need to verify claims: {unverified_claims}. Use verify_claim next."
275
+ elif unchecked_sources:
276
+ next_step_hint = f"\nHINT: You still need to check sources: {unchecked_sources}. Use check_source next."
277
+ else:
278
+ next_step_hint = "\nHINT: You have sufficient evidence. Use the 'decide' action now (allow/flag/remove)."
279
+
280
+ return f"""STEP {step}
281
+
282
+ CONTENT UNDER REVIEW:
283
+ {content}
284
+
285
+ CLAIMS:
286
+ {claims_text}
287
+
288
+ AVAILABLE SOURCES:
289
+ {sources_text}
290
+
291
+ STATUS FLAGS:
292
+ decision_made={flags.get('decision_made', False)}
293
+ sufficient_evidence={sufficient}
294
+ redundant_actions={flags.get('redundant_actions', 0)}
295
+ {last_result}{next_step_hint}
296
+
297
+ Choose your next action (JSON only):"""
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # LLM action call
302
+ # ---------------------------------------------------------------------------
303
+
304
+ def _llm_action(llm: OpenAI, messages: list, step: int, retries: int = 3) -> dict:
305
+ """Call LLM and parse JSON action. Retries on parse failure."""
306
+ last_exc = None
307
+ for attempt in range(retries):
308
+ try:
309
+ resp = llm.chat.completions.create(
310
+ model=MODEL_NAME,
311
+ messages=messages,
312
+ temperature=0.0,
313
+ max_tokens=256,
314
+ timeout=30,
315
+ )
316
+ raw = resp.choices[0].message.content.strip()
317
+ if raw.startswith("```"):
318
+ raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
319
+ return json.loads(raw)
320
+ except json.JSONDecodeError as exc:
321
+ logger.warning("Step %d attempt %d: JSON parse error β€” %s", step, attempt + 1, exc)
322
+ last_exc = exc
323
+ except Exception as exc:
324
+ logger.error("LLM call failed at step %d: %s", step, exc)
325
+ last_exc = exc
326
+ if attempt < retries - 1:
327
+ time.sleep(2 ** attempt)
328
+
329
+ # Raise so the episode fails loudly rather than silently looping
330
+ raise RuntimeError(f"LLM call failed after {retries} attempts at step {step}: {last_exc}")
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Explanation builder
335
+ # ---------------------------------------------------------------------------
336
+
337
+ def _build_explanation(obs_dict: dict, decision: Optional[str]) -> str:
338
+ try:
339
+ from explainability import ExplainabilityEngine, build_reasoning_from_episode
340
+ from models import Observation
341
+ obs = Observation(**obs_dict)
342
+ reasoning = build_reasoning_from_episode(obs=obs, decision=decision or "none")
343
+ return ExplainabilityEngine().generate_explanation(reasoning)
344
+ except Exception as exc:
345
+ return f"(explanation unavailable: {exc})"
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # OpenEnv-compliant logging helpers
350
+ # ---------------------------------------------------------------------------
351
+
352
+ def log_start(task: str, env: str, model: str) -> None:
353
+ print(f"[START] task={task} env={env} model={model}", flush=True)
354
+
355
+
356
+ def log_step(step: int, action: str, reward: float, done: bool, error: str = "null") -> None:
357
+ print(
358
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
359
+ f"done={str(done).lower()} error={error}",
360
+ flush=True,
361
+ )
362
+
363
+
364
+ def log_end(success: bool, steps: int, rewards: List[float], score: float = 0.0) -> None:
365
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
366
+ print(
367
+ f"[END] success={str(success).lower()} steps={steps} score={score:.4f} rewards={rewards_str}",
368
+ flush=True,
369
+ )
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # OpenEnv single-episode runner (primary entry point)
374
+ # ---------------------------------------------------------------------------
375
+
376
+ def run_single_episode(llm: OpenAI) -> dict:
377
+ """
378
+ Run one episode controlled entirely by DIFFICULTY and SCENARIO_ID env vars.
379
+ Prints strict OpenEnv-format logs: [START], [STEP]Γ—n, [END].
380
+ """
381
+ from tasks.registry import TASKS
382
+ from environment import InformationIntegrityEnv
383
+
384
+ # Resolve scenario_id: prefer int index, fall back to string id
385
+ sid: Optional[str | int] = SCENARIO_ID
386
+ if sid is not None:
387
+ try:
388
+ sid = int(sid)
389
+ except (ValueError, TypeError):
390
+ pass # keep as string scenario id
391
+
392
+ task = TASKS[DIFFICULTY]
393
+ scenario_dict = task.get_scenario(sid if sid is not None else 0)
394
+ task_name = scenario_dict.get('id', f'{DIFFICULTY}_unknown')
395
+
396
+ env = InformationIntegrityEnv(scenario=scenario_dict)
397
+ obs = env.reset(difficulty=DIFFICULTY, scenario_id=sid)
398
+
399
+ log_start(task=task_name, env="information-integrity-env", model=MODEL_NAME)
400
+
401
+ messages: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]
402
+ rewards: List[float] = []
403
+ steps_taken = 0
404
+ score = 0.0
405
+
406
+ for step in range(1, MAX_STEPS + 1):
407
+ obs_dict = obs.model_dump()
408
+ messages.append({"role": "user", "content": _observation_prompt(obs_dict)})
409
+
410
+ try:
411
+ action_dict = _llm_action(llm, messages, step)
412
+ except RuntimeError as exc:
413
+ logger.error("Aborting episode: %s", exc)
414
+ break
415
+ messages.append({"role": "assistant", "content": json.dumps(action_dict)})
416
+
417
+ error_str = "null"
418
+ step_reward = 0.0
419
+ done = False
420
+
421
+ try:
422
+ from models import Action
423
+ obs, reward_obj, done, info = env.step(Action(**action_dict))
424
+ step_reward = reward_obj.score
425
+ except Exception as exc:
426
+ error_str = str(exc)[:80].replace("\n", " ")
427
+
428
+ rewards.append(step_reward)
429
+ steps_taken = step
430
+
431
+ log_step(
432
+ step=step,
433
+ action=action_dict.get("type", "unknown"),
434
+ reward=step_reward,
435
+ done=done,
436
+ error=error_str,
437
+ )
438
+
439
+ if done:
440
+ try:
441
+ ep_result = env.episode_result()
442
+ score = float(ep_result.grader_score or 0.0)
443
+ except Exception:
444
+ score = 0.0
445
+ score = min(max(score, 0.005), 0.995)
446
+ break
447
+
448
+ # Compute grader score even if episode didn't complete (LLM failed / max steps)
449
+ if score == 0.0:
450
+ try:
451
+ ep_result = env.episode_result()
452
+ score = float(ep_result.grader_score or 0.0)
453
+ except Exception:
454
+ score = 0.0
455
+ score = min(max(score, 0.005), 0.995) # strictly in (0, 1) as required by validator
456
+
457
+ success = score >= 0.5
458
+ log_end(success=success, steps=steps_taken, rewards=rewards, score=score)
459
+
460
+ return {"score": score, "steps": steps_taken, "success": success}
461
+
462
+
463
+ # ---------------------------------------------------------------------------
464
+ # Single episode runner (legacy β€” used by benchmark loop and CLI)
465
+ # ---------------------------------------------------------------------------
466
+
467
+ def run_episode(
468
+ env: LocalEnvClient | ServerEnvClient,
469
+ llm: OpenAI,
470
+ difficulty: Optional[str] = None,
471
+ scenario_id: Optional[str | int] = None,
472
+ ) -> dict:
473
+ """Run one full episode using compliant log_start/log_step/log_end."""
474
+ messages: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]
475
+
476
+ obs_dict = env.reset(difficulty=difficulty, scenario_id=scenario_id)
477
+
478
+ task_name = f"{difficulty}_{scenario_id}" if difficulty and scenario_id is not None \
479
+ else obs_dict.get("task_difficulty", "unknown")
480
+ log_start(task=task_name, env="information-integrity-env", model=MODEL_NAME)
481
+
482
+ rewards: List[float] = []
483
+ step = 0
484
+ grader_score: Optional[float] = None
485
+
486
+ for step in range(1, MAX_STEPS + 1):
487
+ messages.append({"role": "user", "content": _observation_prompt(obs_dict)})
488
+
489
+ try:
490
+ action_dict = _llm_action(llm, messages, step)
491
+ except RuntimeError as exc:
492
+ break
493
+ messages.append({"role": "assistant", "content": json.dumps(action_dict)})
494
+
495
+ error_str = "null"
496
+ step_reward = 0.0
497
+ done = False
498
+
499
+ try:
500
+ result = env.step(action_dict)
501
+ obs_dict = result["observation"]
502
+ step_reward = result["reward"].get("score", 0.0)
503
+ done = result["done"]
504
+ except Exception as exc:
505
+ error_str = str(exc)[:80].replace("\n", " ")
506
+
507
+ rewards.append(step_reward)
508
+ log_step(step=step, action=action_dict.get("type", "unknown"),
509
+ reward=step_reward, done=done, error=error_str)
510
+
511
+ if done:
512
+ break
513
+
514
+ if isinstance(env, LocalEnvClient):
515
+ try:
516
+ grader_score = env.episode_result().get("grader_score")
517
+ except Exception:
518
+ pass
519
+
520
+ score = max(float(grader_score or 0.005), 0.005) # strictly > 0 as required by validator
521
+ success = score >= 0.5
522
+ log_end(success=success, steps=step, rewards=rewards, score=score)
523
+
524
+ return {
525
+ "difficulty": difficulty,
526
+ "scenario_id": scenario_id,
527
+ "steps": step,
528
+ "grader_score": score, # always a float >= 0.005
529
+ "total_reward": sum(rewards),
530
+ }
531
+
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # Benchmark loop
536
+ # ---------------------------------------------------------------------------
537
+
538
+ def run_benchmark(llm: OpenAI, out_path: Optional[str] = None) -> Dict[str, Any]:
539
+ """
540
+ Loop over all difficulties Γ— all scenarios in each dataset.
541
+ Prints per-episode logs, then the required summary table.
542
+
543
+ Returns
544
+ -------
545
+ dict with per-difficulty aggregates and all raw episode results.
546
+ """
547
+ from tasks.registry import TASKS
548
+
549
+ all_results: Dict[str, List[dict]] = {d: [] for d in DIFFICULTIES}
550
+ rng = random.Random(BENCHMARK_SEED)
551
+
552
+ for difficulty in DIFFICULTIES:
553
+ task = TASKS[difficulty]
554
+ scenarios = task.get_all_scenarios()
555
+ n = len(scenarios)
556
+ logger.info("=== Benchmarking difficulty=%s (%d scenarios) ===", difficulty, n)
557
+
558
+ for idx, sc_dict in enumerate(scenarios):
559
+ # Build a fresh local env for each scenario
560
+ env = LocalEnvClient(
561
+ difficulty=difficulty,
562
+ scenario_id=idx,
563
+ )
564
+ result = run_episode(
565
+ env=env,
566
+ llm=llm,
567
+ difficulty=difficulty,
568
+ scenario_id=idx,
569
+ )
570
+ result["scenario_index"] = idx
571
+ result["scenario_id"] = sc_dict.get("id", str(idx))
572
+ all_results[difficulty].append(result)
573
+
574
+ # ---- Summary ----
575
+ summary: Dict[str, dict] = {}
576
+ print() # blank line before summary
577
+ for difficulty in DIFFICULTIES:
578
+ episodes = all_results[difficulty]
579
+ n = len(episodes)
580
+
581
+ scores = [e["grader_score"] for e in episodes] # always float >= 0.005
582
+ rewards = [e["total_reward"] for e in episodes]
583
+
584
+ avg_score = sum(scores) / len(scores) if scores else 0.005
585
+ avg_reward = sum(rewards) / len(rewards) if rewards else 0.0
586
+
587
+ summary[difficulty] = {
588
+ "avg_score": round(avg_score, 4),
589
+ "avg_reward": round(avg_reward, 4),
590
+ "n": n,
591
+ }
592
+ print(
593
+ f"difficulty={difficulty:<6} "
594
+ f"avg_score={avg_score:.4f} "
595
+ f"avg_reward={avg_reward:.4f} "
596
+ f"n={n}"
597
+ )
598
+
599
+ # ---- Difficulty tier separation check ----
600
+ easy_s = summary["easy"]["avg_score"]
601
+ medium_s = summary["medium"]["avg_score"]
602
+ hard_s = summary["hard"]["avg_score"]
603
+
604
+ if easy_s > medium_s > hard_s:
605
+ print("Difficulty tiers well separated: easy > medium > hard βœ“")
606
+ else:
607
+ print(
608
+ "WARNING: Difficulty tiers not well separated "
609
+ f"(easy={easy_s:.4f}, medium={medium_s:.4f}, hard={hard_s:.4f})"
610
+ )
611
+
612
+ benchmark_output = {
613
+ "summary": summary,
614
+ "episodes": all_results,
615
+ }
616
+
617
+ if out_path:
618
+ with open(out_path, "w", encoding="utf-8") as fh:
619
+ json.dump(benchmark_output, fh, indent=2, ensure_ascii=False)
620
+ logger.info("Benchmark results written to %s", out_path)
621
+
622
+ return benchmark_output
623
+
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # CLI
627
+ # ---------------------------------------------------------------------------
628
+
629
+ def _parse_args() -> argparse.Namespace:
630
+ parser = argparse.ArgumentParser(
631
+ description="InformationIntegrity benchmark agent",
632
+ formatter_class=argparse.RawTextHelpFormatter,
633
+ )
634
+
635
+ mode = parser.add_mutually_exclusive_group()
636
+ mode.add_argument(
637
+ "--benchmark", action="store_true",
638
+ help="Run full benchmark over all difficulties and all scenarios (default mode)",
639
+ )
640
+ mode.add_argument(
641
+ "--local", action="store_true",
642
+ help="Run a single episode in-process (requires --difficulty or --scenario)",
643
+ )
644
+ mode.add_argument(
645
+ "--server", action="store_true",
646
+ help="Run a single episode against a running env server",
647
+ )
648
+
649
+ parser.add_argument("--difficulty", default="medium",
650
+ choices=DIFFICULTIES,
651
+ help="Difficulty tier (used with --local or --server)")
652
+ parser.add_argument("--scenario-id", default=None,
653
+ help="Scenario index (int) or id (str); None = random")
654
+ parser.add_argument("--scenario", default=None,
655
+ help="Path to a pre-collected scenario JSON file (overrides --difficulty)")
656
+ parser.add_argument("--out", default=None,
657
+ help="Write episode/benchmark result JSON to this path")
658
+
659
+ return parser.parse_args()
660
+
661
+
662
+ def main() -> None:
663
+ args = _parse_args()
664
+ llm = _build_client()
665
+
666
+ # ---------- Default: OpenEnv single-episode mode (env-var driven) ----------
667
+ if not args.benchmark and not args.local and not args.server:
668
+ run_single_episode(llm)
669
+ return
670
+
671
+ # ---------- Benchmark ----------
672
+ if args.benchmark:
673
+ run_benchmark(llm, out_path=args.out)
674
+ return
675
+
676
+ # ---------- Single episode ----------
677
+ scenario_id: Optional[str | int] = args.scenario_id
678
+ if scenario_id is not None:
679
+ try:
680
+ scenario_id = int(scenario_id)
681
+ except (ValueError, TypeError):
682
+ pass # keep as string id
683
+
684
+ # Load scenario from file if specified
685
+ scenario_dict: Optional[dict] = None
686
+ if args.scenario:
687
+ with open(args.scenario, "r", encoding="utf-8") as fh:
688
+ scenario_dict = json.load(fh)
689
+
690
+ if args.local:
691
+ env = LocalEnvClient(
692
+ difficulty=args.difficulty,
693
+ scenario_id=scenario_id,
694
+ scenario_dict=scenario_dict,
695
+ )
696
+ else:
697
+ env = ServerEnvClient(base_url=ENV_SERVER_URL)
698
+
699
+ result = run_episode(
700
+ env=env,
701
+ llm=llm,
702
+ difficulty=args.difficulty,
703
+ scenario_id=scenario_id,
704
+ )
705
+
706
+ if args.out:
707
+ with open(args.out, "w", encoding="utf-8") as fh:
708
+ json.dump(result, fh, indent=2, ensure_ascii=False)
709
+ logger.info("Episode result written to %s", args.out)
710
+
711
+
712
+ if __name__ == "__main__":
713
+ main()
models.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic models for the InformationIntegrity OpenEnv environment.
3
+ All models are strict and versioned for deterministic serialisation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from enum import Enum
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from pydantic import BaseModel, Field, model_validator
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Enumerations
16
+ # ---------------------------------------------------------------------------
17
+
18
+ class Stance(str, Enum):
19
+ SUPPORTING = "supporting"
20
+ CONTRADICTING = "contradicting"
21
+ NEUTRAL = "neutral"
22
+
23
+
24
+ class VerificationStatus(str, Enum):
25
+ UNVERIFIED = "unverified"
26
+ TRUE = "true"
27
+ FALSE = "false"
28
+ PARTIALLY_TRUE = "partially_true"
29
+ UNKNOWN = "unknown"
30
+
31
+
32
+ class DecisionType(str, Enum):
33
+ ALLOW = "allow"
34
+ FLAG = "flag"
35
+ REMOVE = "remove"
36
+
37
+
38
+ class ActionType(str, Enum):
39
+ VERIFY_CLAIM = "verify_claim"
40
+ CHECK_SOURCE = "check_source"
41
+ DECIDE = "decide"
42
+
43
+
44
+ class TaskDifficulty(str, Enum):
45
+ EASY = "easy"
46
+ MEDIUM = "medium"
47
+ HARD = "hard"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Core domain models
52
+ # ---------------------------------------------------------------------------
53
+
54
+ class SourceInfo(BaseModel):
55
+ """A single evidence source presented to the agent."""
56
+
57
+ id: str = Field(..., description="Unique source identifier, e.g. 'src_001'")
58
+ title: str = Field(..., description="Headline or document title")
59
+ text: str = Field(..., description="Excerpt or full text snippet")
60
+ url: str = Field(..., description="Canonical URL of the source")
61
+ stance: Stance = Field(..., description="Relationship of source to the primary claim")
62
+ reliability: float = Field(..., ge=0.0, le=1.0,
63
+ description="0=unreliable, 1=highly authoritative")
64
+ source_type: str = Field(default="unknown",
65
+ description="official | news | blog | social | academic")
66
+
67
+ class Config:
68
+ use_enum_values = True
69
+
70
+
71
+ class Claim(BaseModel):
72
+ """A single verifiable claim extracted from the content."""
73
+
74
+ id: str = Field(..., description="Unique claim identifier, e.g. 'claim_001'")
75
+ text: str = Field(..., description="Verbatim claim text")
76
+ verification_status: VerificationStatus = Field(
77
+ default=VerificationStatus.UNVERIFIED,
78
+ description="Agent-updated verification result"
79
+ )
80
+ confidence: float = Field(
81
+ default=0.0, ge=0.0, le=1.0,
82
+ description="Agent confidence in the verification (0–1)"
83
+ )
84
+ verified_by_sources: List[str] = Field(
85
+ default_factory=list,
86
+ description="List of source IDs used to verify this claim"
87
+ )
88
+
89
+ class Config:
90
+ use_enum_values = True
91
+
92
+
93
+ class StatusFlags(BaseModel):
94
+ """Bookkeeping flags that track episode-level constraints."""
95
+
96
+ decision_made: bool = False
97
+ sufficient_evidence: bool = False
98
+ premature_decision: bool = False
99
+ redundant_actions: int = Field(default=0, ge=0)
100
+ max_steps_reached: bool = False
101
+
102
+
103
+ class InteractionRecord(BaseModel):
104
+ """One entry in the interaction history."""
105
+
106
+ step: int
107
+ action_type: str
108
+ action_payload: Dict[str, Any]
109
+ result: Dict[str, Any]
110
+ reward_delta: float = 0.0
111
+
112
+
113
+ class Observation(BaseModel):
114
+ """Full observable state returned to the agent after each step."""
115
+
116
+ content: str = Field(..., description="The piece of content under review")
117
+ claims: List[Claim] = Field(..., description="Extracted claims (may be partially verified)")
118
+ available_sources: List[SourceInfo] = Field(..., description="Evidence pool for this scenario")
119
+ interaction_history: List[InteractionRecord] = Field(default_factory=list)
120
+ status_flags: StatusFlags = Field(default_factory=StatusFlags)
121
+ step_count: int = Field(default=0, ge=0)
122
+ episode_id: str = Field(default="", description="Unique identifier for this episode")
123
+ task_difficulty: str = Field(default="easy")
124
+
125
+ class Config:
126
+ use_enum_values = True
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Action model
131
+ # ---------------------------------------------------------------------------
132
+
133
+ class Action(BaseModel):
134
+ """Agent action submitted to the environment."""
135
+
136
+ type: ActionType = Field(..., description="Which action to perform")
137
+ claim_id: Optional[str] = Field(
138
+ default=None,
139
+ description="Required when type=verify_claim"
140
+ )
141
+ source_id: Optional[str] = Field(
142
+ default=None,
143
+ description="Required when type=check_source"
144
+ )
145
+ decision: Optional[DecisionType] = Field(
146
+ default=None,
147
+ description="Required when type=decide"
148
+ )
149
+ reasoning: Optional[str] = Field(
150
+ default=None,
151
+ description="Agent's free-text reasoning (logged, not evaluated directly)"
152
+ )
153
+
154
+ @model_validator(mode="after")
155
+ def validate_action_payload(self) -> "Action":
156
+ if self.type == ActionType.VERIFY_CLAIM and not self.claim_id:
157
+ raise ValueError("claim_id is required for verify_claim actions")
158
+ if self.type == ActionType.CHECK_SOURCE and not self.source_id:
159
+ raise ValueError("source_id is required for check_source actions")
160
+ if self.type == ActionType.DECIDE and not self.decision:
161
+ raise ValueError("decision is required for decide actions")
162
+ return self
163
+
164
+ class Config:
165
+ use_enum_values = True
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Reward models
170
+ # ---------------------------------------------------------------------------
171
+
172
+ class RewardBreakdown(BaseModel):
173
+ """Itemised reward components for transparency."""
174
+
175
+ verify_claim_reward: float = 0.0
176
+ check_source_reward: float = 0.0
177
+ multi_source_bonus: float = 0.0
178
+ decision_reward: float = 0.0
179
+ premature_penalty: float = 0.0
180
+ redundant_penalty: float = 0.0
181
+ inefficiency_penalty: float = 0.0
182
+ aggression_penalty: float = 0.0
183
+
184
+ @property
185
+ def total(self) -> float:
186
+ return (
187
+ self.verify_claim_reward
188
+ + self.check_source_reward
189
+ + self.multi_source_bonus
190
+ + self.decision_reward
191
+ + self.premature_penalty
192
+ + self.redundant_penalty
193
+ + self.inefficiency_penalty
194
+ + self.aggression_penalty
195
+ )
196
+
197
+
198
+ class Reward(BaseModel):
199
+ """Reward signal returned after each step."""
200
+
201
+ score: float = Field(..., description="Clipped total reward for this step")
202
+ breakdown: RewardBreakdown
203
+ message: str = Field(default="", description="Human-readable reward explanation")
204
+ is_terminal: bool = Field(default=False)
205
+
206
+ class Config:
207
+ use_enum_values = True
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Episode result (returned at episode end)
212
+ # ---------------------------------------------------------------------------
213
+
214
+ class EpisodeResult(BaseModel):
215
+ """Accumulated statistics at the end of an episode."""
216
+
217
+ episode_id: str
218
+ task_difficulty: str
219
+ scenario_index: int
220
+ total_reward: float
221
+ decision: Optional[str] = None
222
+ ground_truth: str
223
+ correct: bool
224
+ steps_taken: int
225
+ claims_verified: int
226
+ sources_checked: int
227
+ grader_score: float = Field(ge=0.0, le=1.0)
228
+ reward_breakdown: RewardBreakdown
229
+ explanation: Optional[str] = None
230
+
231
+ class Config:
232
+ use_enum_values = True
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Scenario (loaded from dataset JSON)
237
+ # ---------------------------------------------------------------------------
238
+
239
+ class ScenarioSource(BaseModel):
240
+ """Source as stored in the dataset file."""
241
+
242
+ id: str
243
+ title: str
244
+ text: str
245
+ url: str
246
+ stance: str
247
+ reliability: float = Field(ge=0.0, le=1.0)
248
+ source_type: str = "unknown"
249
+ # Hidden from agent β€” used only by environment internals
250
+ verification_weight: float = Field(default=1.0, ge=0.0, le=1.0)
251
+
252
+
253
+ class ScenarioClaim(BaseModel):
254
+ """Claim as stored in the dataset file."""
255
+
256
+ id: str
257
+ text: str
258
+ # Hidden ground truth for the verifier
259
+ ground_truth_status: str
260
+ ground_truth_confidence: float = Field(default=1.0, ge=0.0, le=1.0)
261
+
262
+
263
+ class Scenario(BaseModel):
264
+ """A complete evaluation scenario loaded from a dataset file."""
265
+
266
+ id: str
267
+ difficulty: str
268
+ content: str
269
+ claims: List[ScenarioClaim]
270
+ sources: List[ScenarioSource]
271
+ ground_truth: str = Field(..., description="allow | flag | remove")
272
+ topic: str = ""
273
+ tags: List[str] = Field(default_factory=list)
274
+ explanation_hint: str = Field(
275
+ default="",
276
+ description="Template hint for explanation generation"
277
+ )
openenv.yaml ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # openenv.yaml v1.1.0
3
+ # OpenEnv environment specification β€” passes `openenv validate`
4
+ # ============================================================================
5
+
6
+ name: information-integrity-env
7
+ version: "1.1.0"
8
+ author: openenv-content-moderation
9
+ description: >
10
+ A deterministic, benchmarkable content moderation environment where agents
11
+ verify claims, reason over pre-built evidence sources, and make explainable
12
+ moderation decisions (allow / flag / remove).
13
+
14
+ Evaluation runs fully offline β€” no external API calls during episodes.
15
+ Three difficulty tiers (easy / medium / hard) with static scenario datasets
16
+ ensure reproducible, comparable benchmarks across agent runs.
17
+
18
+ license: Apache-2.0
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Server
22
+ # ---------------------------------------------------------------------------
23
+ server:
24
+ type: http
25
+ reset_endpoint: POST /reset
26
+ step_endpoint: POST /step
27
+ health_endpoint: GET /health
28
+ tasks_endpoint: GET /tasks
29
+ grade_endpoint: POST /grade
30
+ collect_endpoint: POST /collect # dataset creation only; not used during eval
31
+ state_endpoint: GET /state/{session_id}
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Observation space
35
+ # ---------------------------------------------------------------------------
36
+ observation:
37
+ type: object
38
+ description: >
39
+ The agent observes the content under review, extracted claims (with live
40
+ verification status), a pre-built evidence pool, and a running interaction
41
+ history. Ground truth is never exposed.
42
+ properties:
43
+ content:
44
+ type: string
45
+ description: The piece of content under moderation review.
46
+ claims:
47
+ type: array
48
+ description: Verifiable claims extracted from the content.
49
+ items:
50
+ type: object
51
+ properties:
52
+ id: { type: string }
53
+ text: { type: string }
54
+ verification_status: { type: string, enum: [unverified, true, false, partially_true, unknown] }
55
+ confidence: { type: number, minimum: 0.0, maximum: 1.0 }
56
+ verified_by_sources: { type: array, items: { type: string } }
57
+ available_sources:
58
+ type: array
59
+ description: Pre-built evidence pool β€” fully deterministic, no live fetching.
60
+ items:
61
+ type: object
62
+ properties:
63
+ id: { type: string }
64
+ title: { type: string }
65
+ text: { type: string }
66
+ url: { type: string }
67
+ stance: { type: string, enum: [supporting, contradicting, neutral] }
68
+ reliability: { type: number, minimum: 0.0, maximum: 1.0 }
69
+ source_type: { type: string }
70
+ interaction_history:
71
+ type: array
72
+ description: Log of all previous actions and their results this episode.
73
+ status_flags:
74
+ type: object
75
+ properties:
76
+ decision_made: { type: boolean }
77
+ sufficient_evidence: { type: boolean }
78
+ premature_decision: { type: boolean }
79
+ redundant_actions: { type: integer }
80
+ max_steps_reached: { type: boolean }
81
+ step_count: { type: integer }
82
+ episode_id: { type: string }
83
+ task_difficulty: { type: string, enum: [easy, medium, hard] }
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Action space
87
+ # ---------------------------------------------------------------------------
88
+ actions:
89
+ verify_claim:
90
+ description: Investigate a specific claim and receive its verification status.
91
+ required_fields: [claim_id]
92
+ optional_fields: [reasoning]
93
+ example:
94
+ type: verify_claim
95
+ claim_id: claim_001
96
+ reasoning: "This is the central assertion β€” verify first."
97
+
98
+ check_source:
99
+ description: Read a source and receive its full text, stance, and reliability.
100
+ required_fields: [source_id]
101
+ optional_fields: [reasoning]
102
+ example:
103
+ type: check_source
104
+ source_id: src_003
105
+ reasoning: "WHO source β€” likely authoritative."
106
+
107
+ decide:
108
+ description: >
109
+ Make the final moderation decision, ending the episode.
110
+ Must verify >= 1 claim and check >= 2 sources first (or incur premature penalty).
111
+ required_fields: [decision]
112
+ optional_fields: [reasoning]
113
+ example:
114
+ type: decide
115
+ decision: remove
116
+ reasoning: "All claims false per three high-reliability sources."
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Reward
120
+ # ---------------------------------------------------------------------------
121
+ reward:
122
+ type: float
123
+ range: [-2.0, 2.0]
124
+ description: >
125
+ Dense step-level reward. Non-sparse by design β€” agents receive positive
126
+ signals for every unique claim verified and source checked.
127
+ See reward_config.yaml for configurable magnitudes.
128
+ components:
129
+ verify_claim_reward: "+0.20 per unique claim verified"
130
+ check_source_reward: "+0.20 per unique source checked"
131
+ multi_source_bonus: "+0.20 when >= 3 unique sources checked"
132
+ decision_correct: "+1.00 exact match with ground truth"
133
+ decision_partial: "+0.50 one step away (flag<->remove, flag<->allow)"
134
+ decision_wrong: "0.00"
135
+ premature_decision: "-0.50 deciding before sufficient evidence"
136
+ redundant_action: "-0.10 re-verifying / re-checking same id"
137
+ step_inefficiency: "-0.05 per step beyond efficient threshold"
138
+ over_aggression: "-0.30 choosing remove when ground truth is allow/flag"
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Tasks (deterministic datasets β€” no live API calls)
142
+ # ---------------------------------------------------------------------------
143
+ tasks:
144
+ - id: easy
145
+ difficulty: easy
146
+ description: >
147
+ Clear-cut claims with overwhelming contradicting evidence from authoritative
148
+ sources (WHO, CDC, NASA, official government). Decision should be
149
+ straightforward for a competent agent.
150
+ dataset: tasks/easy/scenarios.json
151
+ n_scenarios: 5
152
+ grader:
153
+ enabled: true
154
+ class: tasks.graders.EasyGrader
155
+ method: grade
156
+ score_range: [0.0, 1.0]
157
+ weights: { decision: 0.50, evidence: 0.25, reasoning: 0.15, efficiency: 0.10 }
158
+ ideal_max_steps: 8
159
+ ground_truth_distribution: { allow: 1, flag: 1, remove: 3 }
160
+
161
+ - id: medium
162
+ difficulty: medium
163
+ description: >
164
+ Partially true or misleading content. Mixed evidence with some supporting
165
+ and some contradicting sources. Requires careful claim-by-claim verification
166
+ and evidence weighing.
167
+ dataset: tasks/medium/scenarios.json
168
+ n_scenarios: 5
169
+ grader:
170
+ enabled: true
171
+ class: tasks.graders.MediumGrader
172
+ method: grade
173
+ score_range: [0.0, 1.0]
174
+ weights: { decision: 0.35, evidence: 0.35, reasoning: 0.20, efficiency: 0.10 }
175
+ ideal_max_steps: 12
176
+ ground_truth_distribution: { allow: 1, flag: 4, remove: 0 }
177
+
178
+ - id: hard
179
+ difficulty: hard
180
+ description: >
181
+ Conflicting evidence from credible sources; genuinely contested ground truth.
182
+ Requires nuanced multi-claim reasoning, evidence reliability weighting, and
183
+ tolerance for ambiguity. All four scoring dimensions count equally.
184
+ dataset: tasks/hard/scenarios.json
185
+ n_scenarios: 5
186
+ grader:
187
+ enabled: true
188
+ class: tasks.graders.HardGrader
189
+ method: grade
190
+ score_range: [0.0, 1.0]
191
+ weights: { decision: 0.30, evidence: 0.30, reasoning: 0.30, efficiency: 0.10 }
192
+ ideal_max_steps: 18
193
+ ground_truth_distribution: { allow: 0, flag: 5, remove: 0 }
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Grader
197
+ # ---------------------------------------------------------------------------
198
+ grader:
199
+ output_range: [0.0, 1.0]
200
+ dimensions:
201
+ decision_score:
202
+ description: Correctness of the final moderation call (partial credit for adjacent decisions)
203
+ evidence_score:
204
+ description: Fraction of available sources consulted
205
+ reasoning_score:
206
+ description: Fraction of claims fully verified before deciding
207
+ efficiency_score:
208
+ description: Steps used relative to difficulty-specific ideal maximum
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Evaluation protocol
212
+ # ---------------------------------------------------------------------------
213
+ evaluation:
214
+ description: >
215
+ Run inference.py in benchmark mode to evaluate an agent across all 15 scenarios.
216
+ No external API calls are made during evaluation episodes.
217
+ command: python inference.py --benchmark --out results.json
218
+ expected_output_format: |
219
+ difficulty=easy avg_score=X.XXXX avg_reward=X.XXXX n=5
220
+ difficulty=medium avg_score=X.XXXX avg_reward=X.XXXX n=5
221
+ difficulty=hard avg_score=X.XXXX avg_reward=X.XXXX n=5
222
+ tier_validation: easy_avg > medium_avg > hard_avg
223
+ runtime_budget: "< 20 minutes on 2 vCPU / 8 GB RAM"
224
+ logging_protocol: "[START] / [STEP] / [END]"
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Data pipeline (dataset creation only β€” not used during eval)
228
+ # ---------------------------------------------------------------------------
229
+ data_pipeline:
230
+ description: >
231
+ DataCollector is used ONLY to build the static scenario datasets in
232
+ tasks/{difficulty}/scenarios.json. It is NOT called during evaluation episodes.
233
+ backends:
234
+ - { name: serpapi, env_key: SERPAPI_KEY }
235
+ - { name: newsapi, env_key: NEWSAPI_KEY }
236
+ - { name: brave_search, env_key: BRAVE_SEARCH_KEY }
237
+ - { name: google_factcheck, env_key: GOOGLE_FACTCHECK_KEY }
238
+ - { name: duckduckgo, env_key: null, note: "No key required; rate-limited fallback" }
239
+ cache:
240
+ backend: redis
241
+ env_key: REDIS_URL
242
+ default: redis://localhost:6379
243
+ ttl_seconds: 86400
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Constraints
247
+ # ---------------------------------------------------------------------------
248
+ constraints:
249
+ - Ground truth is never exposed in the Observation
250
+ - No external API calls occur during env.step() or env.reset()
251
+ - All reward computations are deterministic
252
+ - Agents must gather evidence before deciding (premature decision penalty applies)
253
+ - Redundant actions (re-checking same id) are penalised
254
+ - Scenario datasets are static JSON β€” reproducible across runs
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Baseline
258
+ # ---------------------------------------------------------------------------
259
+ baseline:
260
+ script: inference.py
261
+ mode: --benchmark
262
+ agent_type: ReAct (LLM-driven)
263
+ logging_protocol: "[START] / [STEP] / [END]"
264
+ model_env_var: MODEL_NAME
265
+ api_base_env_var: API_BASE_URL
266
+ token_env_var: HF_TOKEN
267
+ expected_scores:
268
+ easy: { avg_score: "~0.78", avg_reward: "~1.20" }
269
+ medium: { avg_score: "~0.63", avg_reward: "~0.90" }
270
+ hard: { avg_score: "~0.52", avg_reward: "~0.65" }
pyproject.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.backends.legacy:build"
4
+
5
+ [project]
6
+ name = "information-integrity-env"
7
+ version = "1.1.0"
8
+ description = "OpenEnv-compatible deterministic benchmark for reasoning-based content moderation"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "fastapi>=0.115.0",
14
+ "uvicorn[standard]>=0.32.0",
15
+ "pydantic>=2.10.0",
16
+ "requests>=2.32.0",
17
+ "duckduckgo-search>=6.3.0",
18
+ "redis>=5.2.0",
19
+ "pyyaml>=6.0.0",
20
+ "python-dotenv>=1.0.0",
21
+ "openai>=1.0.0",
22
+ "scikit-learn>=1.4.0",
23
+ "numpy>=1.26.0",
24
+ "gradio>=4.44.0",
25
+ "openenv-core>=0.2.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ server = "server.app:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://huggingface.co/spaces/Ruhis/InfoClassifier"
quality_weighted_aggregator.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ quality_weighted_aggregator.py
3
+ ==============================
4
+ Advanced aggregation system that combines multiple stance signals with
5
+ quality weighting, confidence calibration, and uncertainty handling.
6
+
7
+ This module provides sophisticated methods for combining stance classifications
8
+ from multiple sources, taking into account source reliability, confidence scores,
9
+ and contextual factors to produce more accurate final decisions.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import math
16
+ from typing import Dict, List, Tuple, Optional, Any
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+
20
+ logger = logging.getLogger("quality_weighted_aggregator")
21
+
22
+ @dataclass
23
+ class StanceEvidence:
24
+ """Represents a single piece of stance evidence with metadata."""
25
+ stance: str # "supporting", "contradicting", "neutral"
26
+ confidence: float # 0.0 to 1.0
27
+ source_reliability: float # 0.0 to 1.0
28
+ source_type: str # "official", "news", "academic", etc.
29
+ method: str # "semantic", "keyword", "nli", etc.
30
+ text_length: int # Length of analyzed text
31
+ claim_relevance: float = 1.0 # How relevant the source is to the claim
32
+ temporal_weight: float = 1.0 # Recency weight if applicable
33
+
34
+ class AggregationStrategy(Enum):
35
+ """Different strategies for aggregating stance evidence."""
36
+ WEIGHTED_MAJORITY = "weighted_majority"
37
+ CONFIDENCE_THRESHOLD = "confidence_threshold"
38
+ RELIABILITY_WEIGHTED = "reliability_weighted"
39
+ BAYESIAN_UPDATE = "bayesian_update"
40
+ ENSEMBLE = "ensemble"
41
+
42
+ class QualityWeightedAggregator:
43
+ """
44
+ Advanced aggregation system for combining multiple stance classifications
45
+ with quality weighting and uncertainty handling.
46
+ """
47
+
48
+ def __init__(self, strategy: AggregationStrategy = AggregationStrategy.ENSEMBLE):
49
+ self.strategy = strategy
50
+
51
+ # Source type weights (can be configured)
52
+ self.source_type_weights = {
53
+ "official": 1.2,
54
+ "academic": 1.1,
55
+ "news": 1.0,
56
+ "fact_check": 1.3,
57
+ "social": 0.7,
58
+ "blog": 0.6,
59
+ "unknown": 0.8,
60
+ }
61
+
62
+ # Method confidence multipliers
63
+ self.method_weights = {
64
+ "semantic": 1.1,
65
+ "nli": 1.0,
66
+ "keyword": 0.8,
67
+ "fallback": 0.6,
68
+ "default": 0.3,
69
+ }
70
+
71
+ # Minimum evidence thresholds
72
+ self.min_evidence_count = 2
73
+ self.min_total_weight = 1.0
74
+ self.uncertainty_threshold = 0.3
75
+
76
+ def aggregate_stance_evidence(
77
+ self,
78
+ evidence_list: List[StanceEvidence],
79
+ claim_text: str = "",
80
+ context: Optional[Dict[str, Any]] = None
81
+ ) -> Tuple[str, float, Dict[str, Any]]:
82
+ """
83
+ Aggregate multiple stance evidence pieces into a final decision.
84
+
85
+ Args:
86
+ evidence_list: List of StanceEvidence objects
87
+ claim_text: The original claim text for context
88
+ context: Additional context information
89
+
90
+ Returns:
91
+ (final_stance, confidence, metadata)
92
+ """
93
+ if not evidence_list:
94
+ return "neutral", 0.1, {"reason": "no_evidence"}
95
+
96
+ # Apply the selected aggregation strategy
97
+ if self.strategy == AggregationStrategy.WEIGHTED_MAJORITY:
98
+ return self._weighted_majority_aggregation(evidence_list)
99
+ elif self.strategy == AggregationStrategy.CONFIDENCE_THRESHOLD:
100
+ return self._confidence_threshold_aggregation(evidence_list)
101
+ elif self.strategy == AggregationStrategy.RELIABILITY_WEIGHTED:
102
+ return self._reliability_weighted_aggregation(evidence_list)
103
+ elif self.strategy == AggregationStrategy.BAYESIAN_UPDATE:
104
+ return self._bayesian_update_aggregation(evidence_list)
105
+ else: # ENSEMBLE
106
+ return self._ensemble_aggregation(evidence_list, claim_text, context)
107
+
108
+ def _weighted_majority_aggregation(
109
+ self,
110
+ evidence_list: List[StanceEvidence]
111
+ ) -> Tuple[str, float, Dict[str, Any]]:
112
+ """Simple weighted majority voting."""
113
+ stance_weights = {"supporting": 0.0, "contradicting": 0.0, "neutral": 0.0}
114
+ total_weight = 0.0
115
+
116
+ for evidence in evidence_list:
117
+ weight = self._calculate_evidence_weight(evidence)
118
+ stance_weights[evidence.stance] += weight
119
+ total_weight += weight
120
+
121
+ if total_weight == 0:
122
+ return "neutral", 0.1, {"reason": "zero_weight"}
123
+
124
+ # Normalize weights
125
+ for stance in stance_weights:
126
+ stance_weights[stance] /= total_weight
127
+
128
+ best_stance = max(stance_weights, key=stance_weights.get)
129
+ confidence = stance_weights[best_stance]
130
+
131
+ return best_stance, confidence, {
132
+ "method": "weighted_majority",
133
+ "stance_weights": stance_weights,
134
+ "total_weight": total_weight
135
+ }
136
+
137
+ def _confidence_threshold_aggregation(
138
+ self,
139
+ evidence_list: List[StanceEvidence]
140
+ ) -> Tuple[str, float, Dict[str, Any]]:
141
+ """Aggregation based on confidence thresholds."""
142
+ high_confidence_evidence = [
143
+ e for e in evidence_list
144
+ if e.confidence >= 0.7 and e.source_reliability >= 0.6
145
+ ]
146
+
147
+ if high_confidence_evidence:
148
+ return self._weighted_majority_aggregation(high_confidence_evidence)
149
+
150
+ # Fall back to all evidence if no high-confidence evidence
151
+ medium_confidence_evidence = [
152
+ e for e in evidence_list
153
+ if e.confidence >= 0.4
154
+ ]
155
+
156
+ if medium_confidence_evidence:
157
+ result = self._weighted_majority_aggregation(medium_confidence_evidence)
158
+ # Reduce confidence for medium-confidence aggregation
159
+ return result[0], result[1] * 0.8, {**result[2], "fallback": "medium_confidence"}
160
+
161
+ return "neutral", 0.2, {"reason": "low_confidence_evidence"}
162
+
163
+ def _reliability_weighted_aggregation(
164
+ self,
165
+ evidence_list: List[StanceEvidence]
166
+ ) -> Tuple[str, float, Dict[str, Any]]:
167
+ """Aggregation heavily weighted by source reliability."""
168
+ stance_scores = {"supporting": 0.0, "contradicting": 0.0, "neutral": 0.0}
169
+ total_reliability = 0.0
170
+
171
+ for evidence in evidence_list:
172
+ # Weight primarily by reliability, with confidence as secondary factor
173
+ weight = evidence.source_reliability * (0.5 + 0.5 * evidence.confidence)
174
+ weight *= self.source_type_weights.get(evidence.source_type, 1.0)
175
+
176
+ stance_scores[evidence.stance] += weight
177
+ total_reliability += evidence.source_reliability
178
+
179
+ if total_reliability == 0:
180
+ return "neutral", 0.1, {"reason": "no_reliable_sources"}
181
+
182
+ best_stance = max(stance_scores, key=stance_scores.get)
183
+
184
+ # Confidence based on reliability and consensus
185
+ max_score = stance_scores[best_stance]
186
+ second_max = max(score for stance, score in stance_scores.items() if stance != best_stance)
187
+ margin = max_score - second_max if second_max > 0 else max_score
188
+
189
+ confidence = min(0.95, (max_score / sum(stance_scores.values())) + margin * 0.1)
190
+
191
+ return best_stance, confidence, {
192
+ "method": "reliability_weighted",
193
+ "stance_scores": stance_scores,
194
+ "avg_reliability": total_reliability / len(evidence_list)
195
+ }
196
+
197
+ def _bayesian_update_aggregation(
198
+ self,
199
+ evidence_list: List[StanceEvidence]
200
+ ) -> Tuple[str, float, Dict[str, Any]]:
201
+ """Bayesian updating approach to stance aggregation."""
202
+ # Prior probabilities (neutral prior)
203
+ priors = {"supporting": 0.33, "contradicting": 0.33, "neutral": 0.34}
204
+ posteriors = priors.copy()
205
+
206
+ for evidence in evidence_list:
207
+ # Calculate likelihood based on evidence quality
208
+ reliability_factor = evidence.source_reliability
209
+ confidence_factor = evidence.confidence
210
+ method_factor = self.method_weights.get(evidence.method, 1.0)
211
+
212
+ # Likelihood of observing this evidence given each stance
213
+ if evidence.stance == "supporting":
214
+ likelihood = {
215
+ "supporting": 0.8 * reliability_factor * confidence_factor * method_factor,
216
+ "contradicting": 0.1,
217
+ "neutral": 0.3
218
+ }
219
+ elif evidence.stance == "contradicting":
220
+ likelihood = {
221
+ "supporting": 0.1,
222
+ "contradicting": 0.8 * reliability_factor * confidence_factor * method_factor,
223
+ "neutral": 0.3
224
+ }
225
+ else: # neutral
226
+ likelihood = {
227
+ "supporting": 0.2,
228
+ "contradicting": 0.2,
229
+ "neutral": 0.6 * reliability_factor * confidence_factor * method_factor
230
+ }
231
+
232
+ # Bayesian update
233
+ evidence_prob = sum(posteriors[stance] * likelihood[stance] for stance in posteriors)
234
+ if evidence_prob > 0:
235
+ for stance in posteriors:
236
+ posteriors[stance] = (posteriors[stance] * likelihood[stance]) / evidence_prob
237
+
238
+ best_stance = max(posteriors, key=posteriors.get)
239
+ confidence = posteriors[best_stance]
240
+
241
+ return best_stance, confidence, {
242
+ "method": "bayesian_update",
243
+ "posteriors": posteriors,
244
+ "evidence_count": len(evidence_list)
245
+ }
246
+
247
+ def _ensemble_aggregation(
248
+ self,
249
+ evidence_list: List[StanceEvidence],
250
+ claim_text: str = "",
251
+ context: Optional[Dict[str, Any]] = None
252
+ ) -> Tuple[str, float, Dict[str, Any]]:
253
+ """
254
+ Ensemble method combining multiple aggregation strategies.
255
+ """
256
+ # Get results from different strategies
257
+ strategies = [
258
+ AggregationStrategy.WEIGHTED_MAJORITY,
259
+ AggregationStrategy.RELIABILITY_WEIGHTED,
260
+ AggregationStrategy.BAYESIAN_UPDATE
261
+ ]
262
+
263
+ results = []
264
+ for strategy in strategies:
265
+ old_strategy = self.strategy
266
+ self.strategy = strategy
267
+ try:
268
+ result = self.aggregate_stance_evidence(evidence_list, claim_text, context)
269
+ results.append(result)
270
+ except Exception as e:
271
+ logger.warning("Strategy %s failed: %s", strategy, e)
272
+ finally:
273
+ self.strategy = old_strategy
274
+
275
+ if not results:
276
+ return "neutral", 0.1, {"reason": "all_strategies_failed"}
277
+
278
+ # Combine results with weighted voting
279
+ stance_votes = {"supporting": 0.0, "contradicting": 0.0, "neutral": 0.0}
280
+ total_confidence = 0.0
281
+
282
+ strategy_weights = [1.0, 1.2, 0.8] # Weight reliability-weighted higher
283
+
284
+ for i, (stance, confidence, metadata) in enumerate(results):
285
+ weight = strategy_weights[i] * confidence
286
+ stance_votes[stance] += weight
287
+ total_confidence += confidence
288
+
289
+ if total_confidence == 0:
290
+ return "neutral", 0.1, {"reason": "zero_ensemble_confidence"}
291
+
292
+ # Final decision
293
+ best_stance = max(stance_votes, key=stance_votes.get)
294
+
295
+ # Ensemble confidence calculation
296
+ max_vote = stance_votes[best_stance]
297
+ total_votes = sum(stance_votes.values())
298
+ consensus_strength = max_vote / total_votes if total_votes > 0 else 0
299
+ avg_confidence = total_confidence / len(results)
300
+
301
+ final_confidence = min(0.95, consensus_strength * avg_confidence +
302
+ self._calculate_ensemble_bonus(evidence_list))
303
+
304
+ return best_stance, final_confidence, {
305
+ "method": "ensemble",
306
+ "strategy_results": results,
307
+ "stance_votes": stance_votes,
308
+ "consensus_strength": consensus_strength,
309
+ "avg_confidence": avg_confidence
310
+ }
311
+
312
+ def _calculate_evidence_weight(self, evidence: StanceEvidence) -> float:
313
+ """Calculate the weight of a piece of evidence."""
314
+ base_weight = evidence.confidence * evidence.source_reliability
315
+
316
+ # Apply source type multiplier
317
+ type_multiplier = self.source_type_weights.get(evidence.source_type, 1.0)
318
+
319
+ # Apply method multiplier
320
+ method_multiplier = self.method_weights.get(evidence.method, 1.0)
321
+
322
+ # Apply relevance and temporal weights
323
+ relevance_weight = evidence.claim_relevance
324
+ temporal_weight = evidence.temporal_weight
325
+
326
+ # Text length bonus (longer analysis generally more reliable)
327
+ length_bonus = min(1.2, 1.0 + (evidence.text_length / 1000) * 0.1)
328
+
329
+ final_weight = (base_weight * type_multiplier * method_multiplier *
330
+ relevance_weight * temporal_weight * length_bonus)
331
+
332
+ return max(0.01, final_weight) # Minimum weight to avoid zero
333
+
334
+ def _calculate_ensemble_bonus(self, evidence_list: List[StanceEvidence]) -> float:
335
+ """Calculate bonus confidence for ensemble methods."""
336
+ # Bonus for having multiple high-quality sources
337
+ high_quality_count = sum(
338
+ 1 for e in evidence_list
339
+ if e.source_reliability >= 0.7 and e.confidence >= 0.6
340
+ )
341
+
342
+ # Bonus for method diversity
343
+ unique_methods = len(set(e.method for e in evidence_list))
344
+
345
+ # Bonus for source type diversity
346
+ unique_source_types = len(set(e.source_type for e in evidence_list))
347
+
348
+ quality_bonus = min(0.15, high_quality_count * 0.03)
349
+ method_bonus = min(0.1, unique_methods * 0.02)
350
+ type_bonus = min(0.1, unique_source_types * 0.02)
351
+
352
+ return quality_bonus + method_bonus + type_bonus
353
+
354
+ def assess_uncertainty(self, evidence_list: List[StanceEvidence]) -> Dict[str, Any]:
355
+ """
356
+ Assess the uncertainty in the stance classification.
357
+
358
+ Returns:
359
+ Dictionary with uncertainty metrics
360
+ """
361
+ if not evidence_list:
362
+ return {"high_uncertainty": True, "reason": "no_evidence"}
363
+
364
+ # Calculate stance distribution
365
+ stance_weights = {"supporting": 0.0, "contradicting": 0.0, "neutral": 0.0}
366
+ total_weight = 0.0
367
+
368
+ for evidence in evidence_list:
369
+ weight = self._calculate_evidence_weight(evidence)
370
+ stance_weights[evidence.stance] += weight
371
+ total_weight += weight
372
+
373
+ if total_weight == 0:
374
+ return {"high_uncertainty": True, "reason": "zero_weight"}
375
+
376
+ # Normalize
377
+ for stance in stance_weights:
378
+ stance_weights[stance] /= total_weight
379
+
380
+ # Calculate entropy (uncertainty measure)
381
+ entropy = -sum(p * math.log2(p) for p in stance_weights.values() if p > 0)
382
+ max_entropy = math.log2(3) # Maximum entropy for 3 classes
383
+ normalized_entropy = entropy / max_entropy
384
+
385
+ # Calculate margin between top two stances
386
+ sorted_weights = sorted(stance_weights.values(), reverse=True)
387
+ margin = sorted_weights[0] - sorted_weights[1] if len(sorted_weights) > 1 else sorted_weights[0]
388
+
389
+ # Assess evidence quality
390
+ avg_reliability = sum(e.source_reliability for e in evidence_list) / len(evidence_list)
391
+ avg_confidence = sum(e.confidence for e in evidence_list) / len(evidence_list)
392
+
393
+ # Determine if uncertainty is high
394
+ high_uncertainty = (
395
+ normalized_entropy > 0.8 or # High entropy
396
+ margin < 0.2 or # Small margin
397
+ avg_reliability < 0.4 or # Low reliability
398
+ avg_confidence < 0.5 or # Low confidence
399
+ len(evidence_list) < self.min_evidence_count # Insufficient evidence
400
+ )
401
+
402
+ return {
403
+ "high_uncertainty": high_uncertainty,
404
+ "entropy": entropy,
405
+ "normalized_entropy": normalized_entropy,
406
+ "margin": margin,
407
+ "avg_reliability": avg_reliability,
408
+ "avg_confidence": avg_confidence,
409
+ "evidence_count": len(evidence_list),
410
+ "stance_distribution": stance_weights
411
+ }
412
+
413
+ def create_stance_evidence_from_classification(
414
+ stance: str,
415
+ confidence: float,
416
+ source_reliability: float,
417
+ source_type: str,
418
+ method: str,
419
+ text_length: int,
420
+ claim_relevance: float = 1.0,
421
+ temporal_weight: float = 1.0
422
+ ) -> StanceEvidence:
423
+ """Helper function to create StanceEvidence from classification results."""
424
+ return StanceEvidence(
425
+ stance=stance,
426
+ confidence=confidence,
427
+ source_reliability=source_reliability,
428
+ source_type=source_type,
429
+ method=method,
430
+ text_length=text_length,
431
+ claim_relevance=claim_relevance,
432
+ temporal_weight=temporal_weight
433
+ )
reasoning.py ADDED
@@ -0,0 +1,862 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reasoning.py
3
+ ============
4
+ Deterministic reasoning engine for InformationIntegrityEnv.
5
+
6
+ Architecture
7
+ ------------
8
+ EvidenceAggregator β€” turns List[ScoredSource] into a numeric evidence score
9
+ RiskClassifier β€” detects high-risk claim domains (medical, violence, etc.)
10
+ SafetyRules β€” override decisions when evidence is insufficient
11
+ DeterministicReasoningEngine β€” combines all into a StructuredReasoning result
12
+
13
+ Evidence score formula
14
+ ----------------------
15
+ For each source:
16
+ contribution = stance_weight(stance) Γ— reliability Γ— similarity_boost(similarity)
17
+
18
+ Stance weights:
19
+ supporting β†’ +1.0 (positive contribution)
20
+ contradicting β†’ -1.0 (negative contribution)
21
+ neutral β†’ 0.0 (ignored)
22
+
23
+ similarity_boost(sim):
24
+ sim β‰₯ 0.60 β†’ 1.20 (high confidence amplifier)
25
+ sim β‰₯ 0.40 β†’ 1.00 (normal)
26
+ sim < 0.40 β†’ 0.60 (low confidence dampener β€” source is weakly relevant)
27
+
28
+ evidence_score = Ξ£ contributions (not normalised; bounded by SafetyRules logic)
29
+
30
+ High-risk multiplier
31
+ --------------------
32
+ When a claim is classified as high-risk (medical misinformation, harmful
33
+ actions, public health emergencies), the evidence score is multiplied by
34
+ a configurable factor (default 1.5) to lower the bar for REMOVE.
35
+
36
+ Decision thresholds (default)
37
+ ------------------------------
38
+ score > 0.30 β†’ ALLOW
39
+ score < -0.50 β†’ REMOVE (lowered from -0.70; high-risk claims use -0.35)
40
+ otherwise β†’ FLAG
41
+
42
+ Safety rules (all override the score-based decision)
43
+ ------------------------------------------------------
44
+ 1. No sources at all β†’ FLAG
45
+ 2. All sources are neutral β†’ FLAG
46
+ 3. REMOVE needs β‰₯ min_remove_sources contradicting sources
47
+ 4. REMOVE needs contradicting reliability β‰₯ min_remove_reliability
48
+ 5. Score near boundary (|score| < boundary_margin) β†’ FLAG
49
+ 6. ALLOW requires β‰₯1 high-reliability supporting source
50
+ 7. HIGH-RISK + strong_contra + β‰₯1 authoritative source β†’ force REMOVE
51
+
52
+ Multiple-source confidence boost
53
+ ---------------------------------
54
+ When β‰₯3 contradicting sources agree, confidence is boosted to "high"
55
+ regardless of individual reliability scores.
56
+
57
+ All thresholds are configurable via reasoning_config.yaml or env-vars.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import logging
63
+ import os
64
+ from dataclasses import dataclass, field
65
+ from typing import Dict, List, Optional, Tuple
66
+
67
+ import yaml
68
+
69
+ from retrieval import ScoredSource
70
+
71
+ logger = logging.getLogger("reasoning")
72
+
73
+ REASONING_CONFIG_PATH = os.getenv("REASONING_CONFIG", "reasoning_config.yaml")
74
+
75
+
76
+ # ─────────────────────────────────────────────────────────────────────────────
77
+ # Config
78
+ # ─────────────────────────────────────────────────────────────────────────────
79
+
80
+ _DEFAULTS: Dict[str, float | int] = {
81
+ # Decision thresholds
82
+ "allow_threshold": 0.30, # score > this β†’ ALLOW
83
+ "remove_threshold": -0.50, # score < this β†’ REMOVE
84
+ "boundary_margin": 0.15, # |score| < this β†’ force FLAG
85
+ # High-risk domain settings
86
+ "high_risk_multiplier": 1.50, # amplify score magnitude for risky claims
87
+ "high_risk_remove_threshold": -0.35, # lower REMOVE bar for high-risk claims
88
+ # Similarity boost knees
89
+ "high_sim_cutoff": 0.60,
90
+ "low_sim_cutoff": 0.40,
91
+ "high_sim_boost": 1.20,
92
+ "low_sim_boost": 0.60,
93
+ # Safety rule thresholds
94
+ "min_remove_sources": 2, # int: min contradicting sources for REMOVE
95
+ "min_remove_reliability": 0.35, # avg reliability of contra sources
96
+ # Consensus-based confidence scaling
97
+ # Confidence level = min(1.0, base + n_contra * per_source_boost)
98
+ "consensus_base_confidence": 0.40, # starting confidence with 1 contra source
99
+ "consensus_per_source_boost": 0.12, # +0.12 per additional contradicting source
100
+ "consensus_high_cutoff": 0.75, # β‰₯ this scaled value β†’ "high"
101
+ "consensus_medium_cutoff": 0.50, # β‰₯ this scaled value β†’ "medium"
102
+ # No-support confidence boost
103
+ "no_support_bonus": 0.15, # added when zero supporting sources found
104
+ # Settled-science auto-remove
105
+ "settled_science_remove": 1, # 1=enabled, 0=disabled
106
+ # Confidence descriptors (fallback when consensus logic doesn't apply)
107
+ "high_confidence_threshold": 0.50,
108
+ "low_confidence_threshold": 0.20,
109
+ }
110
+
111
+
112
+ class ReasoningConfig:
113
+ def __init__(self, path: str = REASONING_CONFIG_PATH) -> None:
114
+ self._cfg = dict(_DEFAULTS)
115
+ if os.path.exists(path):
116
+ with open(path, "r", encoding="utf-8") as fh:
117
+ loaded = yaml.safe_load(fh) or {}
118
+ self._cfg.update(loaded)
119
+
120
+ def __getattr__(self, name: str):
121
+ if name == "_cfg":
122
+ raise AttributeError(name)
123
+ try:
124
+ return self._cfg[name]
125
+ except KeyError:
126
+ raise AttributeError(f"ReasoningConfig has no attribute '{name}'")
127
+
128
+
129
+ _CFG = ReasoningConfig()
130
+
131
+
132
+ # ─────────────────────────────────────────────────────────────────────────────
133
+ # Risk Classifier β€” identifies high-stakes claim domains
134
+ # ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ import re as _re
137
+
138
+ # Each entry: (domain_name, compiled_regex_for_claim_text)
139
+ # A claim matching ANY pattern is tagged as high-risk.
140
+ _RISK_PATTERNS: List[Tuple[str, "_re.Pattern"]] = [
141
+ # Medical / health misinformation (highest harm)
142
+ ("medical", _re.compile(
143
+ r"\b(vaccine|vaccination|vaxx?|covid|coronavirus|sars|flu|cancer|"
144
+ r"cure|cures|treat|treatment|drug|medication|dose|overdose|"
145
+ r"autism|5g|radiation|chemotherapy|hiv|aids|ebola|"
146
+ r"causes?\s+death|kills?|deadly|toxic|poison)\b",
147
+ _re.IGNORECASE,
148
+ )),
149
+ # Harmful actions / violence incitement
150
+ ("harmful_action", _re.compile(
151
+ r"\b(drink\s+bleach|inject\s+disinfectant|eat\s+tide|tide\s+pod|"
152
+ r"ivermectin\s+for\s+human|horse\s+dewormer|"
153
+ r"suicide\s+method|how\s+to\s+harm|self.harm|bomb\s+making|"
154
+ r"attack|assassin|terrorist|shoot|stab|murder)\b",
155
+ _re.IGNORECASE,
156
+ )),
157
+ # Public health emergencies
158
+ ("public_health", _re.compile(
159
+ r"\b(pandemic|epidemic|outbreak|spread|contagious|"
160
+ r"herd\s+immunity|lockdown|quarantine|mask|ventilator|"
161
+ r"hospital\s+overwhelm|icu|death\s+toll)\b",
162
+ _re.IGNORECASE,
163
+ )),
164
+ # Election / democratic integrity
165
+ ("election", _re.compile(
166
+ r"\b(election|vote|ballot|voter\s+fraud|stolen\s+election|"
167
+ r"rigged|electoral|polling|count|recount|certification)\b",
168
+ _re.IGNORECASE,
169
+ )),
170
+ # Climate / environment denial used to block policy
171
+ ("climate", _re.compile(
172
+ r"\b(climate\s+change|global\s+warming|carbon|emissions?|"
173
+ r"ipcc|greenhouse|sea\s+level|arctic|glacier)\b",
174
+ _re.IGNORECASE,
175
+ )),
176
+ ]
177
+
178
+ # Authoritative domains that count as "official contradiction"
179
+ _AUTHORITATIVE_DOMAINS = {
180
+ "who.int", "cdc.gov", "nih.gov", "fda.gov", "nhs.uk",
181
+ "ecdc.europa.eu", "nasa.gov", "noaa.gov", "reuters.com",
182
+ "apnews.com", "bbc.com", "bbc.co.uk", "snopes.com",
183
+ "factcheck.org", "politifact.com", "fullfact.org",
184
+ }
185
+
186
+
187
+ class RiskClassifier:
188
+ """
189
+ Classifies a claim into a risk domain.
190
+ High-risk claims get a score multiplier that lowers the REMOVE threshold,
191
+ mirroring how platforms like Meta and YouTube apply stricter rules to
192
+ medical misinformation and election integrity content.
193
+ """
194
+
195
+ def classify(self, claim_text: str) -> Optional[str]:
196
+ """
197
+ Returns the risk domain name (e.g. 'medical') or None if not high-risk.
198
+ """
199
+ for domain, pattern in _RISK_PATTERNS:
200
+ if pattern.search(claim_text):
201
+ return domain
202
+ return None
203
+
204
+ def is_authoritative_contra(self, sources: "List[ScoredSource]") -> bool:
205
+ """
206
+ Returns True if at least one contradicting source comes from an
207
+ authoritative domain (WHO, CDC, Reuters, etc.).
208
+ """
209
+ for src in sources:
210
+ if src.stance == "contradicting":
211
+ domain = self._extract_domain(src.url or "")
212
+ if any(auth in domain for auth in _AUTHORITATIVE_DOMAINS):
213
+ return True
214
+ return False
215
+
216
+ @staticmethod
217
+ def _extract_domain(url: str) -> str:
218
+ url = url.lower().split("//")[-1]
219
+ return url.split("/")[0]
220
+
221
+
222
+ # ─────────────────────────────────────────────────────────────────────────────
223
+ # Settled Science Detector
224
+ # ─────────────────────────────────────────────────────────────────────────────
225
+
226
+ # Claims where scientific consensus is overwhelming (>97% of experts agree).
227
+ # When a claim matches AND contradicting sources exist, the system treats it as
228
+ # settled β€” confidence is forced to "high" and decision β†’ REMOVE regardless of
229
+ # source count or reliability scores.
230
+ #
231
+ # Each entry: (topic_label, regex_that_matches_the_false_claim)
232
+ _SETTLED_SCIENCE_CLAIMS: List[Tuple[str, "_re.Pattern"]] = [
233
+ ("flat_earth", _re.compile(
234
+ r"\b(earth\s+is\s+flat|flat\s+earth|earth\s+isn.t?\s+(round|spherical|a\s+globe))\b",
235
+ _re.IGNORECASE,
236
+ )),
237
+ ("vaccines_autism", _re.compile(
238
+ r"\bvaccines?\s+(cause[sd]?|linked?\s+to|lead[s]?\s+to)\s+autism\b",
239
+ _re.IGNORECASE,
240
+ )),
241
+ ("climate_hoax", _re.compile(
242
+ r"\bclimate\s+change\s+is\s+(a\s+)?(hoax|fake|not\s+real|fabricated|made\s+up)\b",
243
+ _re.IGNORECASE,
244
+ )),
245
+ ("evolution_denial", _re.compile(
246
+ r"\b(evolution\s+is\s+(false|fake|a\s+lie|wrong|disproven)|humans?\s+didn.t?\s+evolve)\b",
247
+ _re.IGNORECASE,
248
+ )),
249
+ ("moon_landing_hoax", _re.compile(
250
+ r"\b(moon\s+landing\s+(was\s+)?(fake|faked|hoax|staged)|never\s+landed\s+on\s+(the\s+)?moon)\b",
251
+ _re.IGNORECASE,
252
+ )),
253
+ ("5g_covid", _re.compile(
254
+ r"\b5g\s+(causes?|spreads?|transmits?|created?)\s+(covid|coronavirus|virus|cancer)\b",
255
+ _re.IGNORECASE,
256
+ )),
257
+ ("bleach_cure", _re.compile(
258
+ r"\b(drink(ing)?\s+bleach|bleach\s+cures?|mms\s+cures?|chlorine\s+dioxide\s+cure)\b",
259
+ _re.IGNORECASE,
260
+ )),
261
+ ("hiv_not_real", _re.compile(
262
+ r"\b(hiv\s+(doesn.t?\s+cause|doesn.t?\s+exist|is\s+fake)|aids\s+is\s+(a\s+)?(hoax|fake))\b",
263
+ _re.IGNORECASE,
264
+ )),
265
+ ]
266
+
267
+
268
+ class SettledScienceDetector:
269
+ """
270
+ Detects claims that contradict overwhelming scientific consensus.
271
+ When matched + contradicting evidence exists, confidence is forced to
272
+ "high" and the decision is escalated directly to REMOVE.
273
+ """
274
+
275
+ def is_settled(self, claim_text: str) -> Optional[str]:
276
+ """
277
+ Returns the topic label (e.g. 'flat_earth') if the claim contradicts
278
+ settled science, otherwise None.
279
+ """
280
+ for label, pattern in _SETTLED_SCIENCE_CLAIMS:
281
+ if pattern.search(claim_text):
282
+ return label
283
+ return None
284
+
285
+
286
+ # ─────────────────────────────────────────────────────────────────────────────
287
+ # Data classes
288
+ # ─────────────────────────────────────────────────────────────────────────────
289
+
290
+ @dataclass
291
+ class SourceEvidence:
292
+ """Per-source evidence contribution (for explainability)."""
293
+ source_id: str
294
+ title: str
295
+ stance: str
296
+ reliability: float
297
+ similarity: float
298
+ contribution: float # signed score contribution
299
+
300
+
301
+ @dataclass
302
+ class StructuredReasoning:
303
+ """
304
+ Complete reasoning artifact produced by DeterministicReasoningEngine.
305
+ Consumed by ExplainabilityEngine and the environment's decide handler.
306
+ """
307
+ # Core decision output
308
+ decision: str # "allow" | "flag" | "remove"
309
+ evidence_score: float # raw aggregated score
310
+ confidence: str # "high" | "medium" | "low"
311
+
312
+ # Per-source breakdown
313
+ source_evidence: List[SourceEvidence] = field(default_factory=list)
314
+
315
+ # Counts
316
+ n_supporting: int = 0
317
+ n_contradicting: int = 0
318
+ n_neutral: int = 0
319
+ n_total: int = 0
320
+
321
+ # Safety rule audit trail
322
+ safety_rules_fired: List[str] = field(default_factory=list)
323
+
324
+ # Risk / settled-science classification
325
+ risk_domain: Optional[str] = None # e.g. "medical", "election"
326
+ settled_science_topic: Optional[str] = None # e.g. "flat_earth", None
327
+
328
+ # Human-readable summary
329
+ rationale: str = ""
330
+
331
+ def to_dict(self) -> dict:
332
+ return {
333
+ "decision": self.decision,
334
+ "evidence_score": round(self.evidence_score, 4),
335
+ "confidence": self.confidence,
336
+ "risk_domain": self.risk_domain,
337
+ "settled_science_topic": self.settled_science_topic,
338
+ "n_supporting": self.n_supporting,
339
+ "n_contradicting": self.n_contradicting,
340
+ "n_neutral": self.n_neutral,
341
+ "n_total": self.n_total,
342
+ "safety_rules_fired": self.safety_rules_fired,
343
+ "rationale": self.rationale,
344
+ "source_breakdown": [
345
+ {
346
+ "id": e.source_id,
347
+ "title": e.title[:80],
348
+ "stance": e.stance,
349
+ "reliability": round(e.reliability, 3),
350
+ "similarity": round(e.similarity, 3),
351
+ "contribution": round(e.contribution, 4),
352
+ }
353
+ for e in self.source_evidence
354
+ ],
355
+ }
356
+
357
+
358
+ # ─────────────────────────────────────���───────────────────────────────────────
359
+ # Part 1 β€” Evidence Aggregator
360
+ # ─────────────────────────────────────────────────────────────────────────────
361
+
362
+ class EvidenceAggregator:
363
+ """
364
+ Converts a list of scored+filtered sources into a signed numeric score
365
+ and a per-source breakdown for transparency.
366
+ """
367
+
368
+ # Stance β†’ sign of contribution
369
+ _STANCE_WEIGHT = {
370
+ "supporting": +1.0,
371
+ "contradicting": -1.0,
372
+ "neutral": 0.0,
373
+ }
374
+
375
+ # Reliability threshold for "high-quality" source classification
376
+ _HI_REL = 0.65
377
+
378
+ def __init__(self, config: Optional[ReasoningConfig] = None) -> None:
379
+ self._cfg = config or _CFG
380
+
381
+ def aggregate(self, sources: List[ScoredSource]) -> Tuple[float, List[SourceEvidence]]:
382
+ """
383
+ Returns (evidence_score, per_source_evidence_list).
384
+
385
+ evidence_score is the sum of signed contributions. It is NOT
386
+ normalised β€” callers compare it against the configured thresholds.
387
+ """
388
+ evidence: List[SourceEvidence] = []
389
+ total_score = 0.0
390
+
391
+ for src in sources:
392
+ stance_w = self._STANCE_WEIGHT.get(src.stance, 0.0)
393
+ sim_boost = self._sim_boost(src.similarity)
394
+ contribution = stance_w * src.reliability * sim_boost
395
+
396
+ evidence.append(SourceEvidence(
397
+ source_id = src.id,
398
+ title = src.title,
399
+ stance = src.stance,
400
+ reliability = src.reliability,
401
+ similarity = src.similarity,
402
+ contribution = round(contribution, 4),
403
+ ))
404
+ total_score += contribution
405
+
406
+ return round(total_score, 4), evidence
407
+
408
+ def classify_evidence_state(self, sources: List[ScoredSource]) -> str:
409
+ """
410
+ Categorises the overall state of evidence. Used by the reasoning
411
+ engine for calibrated confidence and safety-rule decisions.
412
+
413
+ Returns one of:
414
+ "no_evidence" β€” no relevant sources at all
415
+ "strong_contra" β€” β‰₯2 high-reliability contradicting sources
416
+ "weak_contra" β€” contradicting sources but low reliability
417
+ "strong_support" β€” β‰₯2 high-reliability supporting sources
418
+ "weak_support" β€” some supporting but low reliability
419
+ "conflicting" β€” both high-reliability supporting AND contradicting
420
+ "neutral_only" β€” sources exist but all neutral
421
+ """
422
+ if not sources:
423
+ return "no_evidence"
424
+
425
+ contra = [s for s in sources if s.stance == "contradicting"]
426
+ supp = [s for s in sources if s.stance == "supporting"]
427
+ hi_contra = [s for s in contra if s.reliability >= self._HI_REL]
428
+ hi_supp = [s for s in supp if s.reliability >= self._HI_REL]
429
+
430
+ if hi_contra and hi_supp:
431
+ return "conflicting"
432
+ if len(hi_contra) >= 2:
433
+ return "strong_contra"
434
+ if contra:
435
+ return "weak_contra"
436
+ if len(hi_supp) >= 2:
437
+ return "strong_support"
438
+ if supp:
439
+ return "weak_support"
440
+ return "neutral_only"
441
+
442
+ def _sim_boost(self, sim: float) -> float:
443
+ if sim >= self._cfg.high_sim_cutoff:
444
+ return self._cfg.high_sim_boost
445
+ if sim >= self._cfg.low_sim_cutoff:
446
+ return 1.0
447
+ return self._cfg.low_sim_boost
448
+
449
+
450
+ # ─────────────────────────────────────────────────────────────────────────────
451
+ # Part 2 β€” Safety Rules
452
+ # ─────────────────────────────────────────────────────────────────────────────
453
+
454
+ class SafetyRules:
455
+ """
456
+ Post-processes the score-based decision with hard safety constraints.
457
+ Returns (final_decision, list_of_rules_fired).
458
+ """
459
+
460
+ def __init__(self, config: Optional[ReasoningConfig] = None) -> None:
461
+ self._cfg = config or _CFG
462
+
463
+ def apply(
464
+ self,
465
+ score_decision: str,
466
+ evidence_score: float,
467
+ sources: List[ScoredSource],
468
+ evidence: List[SourceEvidence],
469
+ evidence_state: str = "unknown",
470
+ risk_domain: Optional[str] = None,
471
+ has_authoritative_contra: bool = False,
472
+ ) -> Tuple[str, List[str]]:
473
+ """
474
+ Returns (final_decision, fired_rules).
475
+
476
+ Rules are evaluated in priority order; first match wins for decision
477
+ overrides, but ALL fired rules are reported for transparency.
478
+ """
479
+ fired: List[str] = []
480
+ final = score_decision
481
+
482
+ # Rule 1: No sources at all β†’ must FLAG
483
+ if not sources:
484
+ fired.append("NO_SOURCES: no evidence retrieved β€” defaulting to FLAG")
485
+ return "flag", fired
486
+
487
+ # Rule 2: All sources neutral β†’ FLAG (can't confirm or deny)
488
+ if all(s.stance == "neutral" for s in sources):
489
+ fired.append("ALL_NEUTRAL: all sources are neutral β€” defaulting to FLAG")
490
+ final = "flag"
491
+
492
+ # Rule 3: REMOVE requires sufficient contradicting evidence
493
+ if final == "remove":
494
+ contra = [s for s in sources if s.stance == "contradicting"]
495
+ if len(contra) < int(self._cfg.min_remove_sources):
496
+ fired.append(
497
+ f"INSUFFICIENT_CONTRA: only {len(contra)} contradicting source(s), "
498
+ f"need β‰₯{int(self._cfg.min_remove_sources)} for REMOVE β†’ downgrading to FLAG"
499
+ )
500
+ final = "flag"
501
+
502
+ # Rule 4: REMOVE requires contradicting sources to be reliable
503
+ if final == "remove":
504
+ contra = [s for s in sources if s.stance == "contradicting"]
505
+ if contra:
506
+ avg_rel = sum(s.reliability for s in contra) / len(contra)
507
+ if avg_rel < self._cfg.min_remove_reliability:
508
+ fired.append(
509
+ f"LOW_CONTRA_RELIABILITY: avg reliability of contradicting "
510
+ f"sources={avg_rel:.2f} < {self._cfg.min_remove_reliability:.2f} β†’ "
511
+ f"downgrading to FLAG"
512
+ )
513
+ final = "flag"
514
+
515
+ # Rule 5: Score near boundary β†’ resolve uncertainty with FLAG
516
+ boundary = self._cfg.boundary_margin
517
+ if abs(evidence_score) < boundary and final != "flag":
518
+ fired.append(
519
+ f"BOUNDARY_UNCERTAINTY: |score|={abs(evidence_score):.3f} < "
520
+ f"margin={boundary:.2f} β€” insufficient evidence β†’ FLAG"
521
+ )
522
+ final = "flag"
523
+
524
+ # Rule 6: ALLOW requires at least one credible supporting source.
525
+ if final == "allow":
526
+ hi_rel_support = [
527
+ s for s in sources
528
+ if s.stance == "supporting" and s.reliability >= 0.60
529
+ ]
530
+ if not hi_rel_support:
531
+ fired.append(
532
+ "NO_CREDIBLE_SUPPORT: decision was ALLOW but no high-reliability "
533
+ "supporting sources found β€” downgrading to FLAG"
534
+ )
535
+ final = "flag"
536
+
537
+ # Rule 7: HIGH-RISK + strong_contra + authoritative source β†’ force REMOVE
538
+ # Mirrors Meta/YouTube policy: medical/election misinformation with official
539
+ # fact-check contradictions is removed automatically, not just flagged.
540
+ if (
541
+ risk_domain is not None
542
+ and evidence_state in ("strong_contra", "weak_contra")
543
+ and has_authoritative_contra
544
+ and final in ("flag", "remove")
545
+ ):
546
+ fired.append(
547
+ f"HIGH_RISK_AUTO_REMOVE: claim is in high-risk domain '{risk_domain}' "
548
+ f"and contradicted by an authoritative source β€” forcing REMOVE"
549
+ )
550
+ final = "remove"
551
+
552
+ return final, fired
553
+
554
+
555
+ # ─────────────────────────────────────────────────────────────────────────────
556
+ # Part 3 β€” Deterministic Reasoning Engine
557
+ # ─────────────────────────────────────────────────────────────────────────────
558
+
559
+ class DeterministicReasoningEngine:
560
+ """
561
+ Main entry point. Consumes a list of already-ranked ScoredSources
562
+ (output of RetrievalPipeline) and produces a StructuredReasoning.
563
+
564
+ Usage
565
+ -----
566
+ engine = DeterministicReasoningEngine()
567
+ result = engine.reason(claim_text="5G causes COVID", sources=scored_sources)
568
+ # result.decision β†’ "flag" | "allow" | "remove"
569
+ # result.to_dict() β†’ full JSON-serialisable breakdown
570
+ """
571
+
572
+ def __init__(self, config: Optional[ReasoningConfig] = None) -> None:
573
+ self._cfg = config or _CFG
574
+ self._aggregator = EvidenceAggregator(config)
575
+ self._safety = SafetyRules(config)
576
+ self._risk = RiskClassifier()
577
+ self._settled = SettledScienceDetector()
578
+
579
+ def reason(
580
+ self,
581
+ claim_text: str,
582
+ sources: List[ScoredSource],
583
+ ) -> StructuredReasoning:
584
+ """
585
+ Full deterministic reasoning pass for one claim against its sources.
586
+ """
587
+ # Step 1: classify evidence state
588
+ evidence_state = self._aggregator.classify_evidence_state(sources)
589
+
590
+ # Step 2: classify claim risk + settled-science
591
+ risk_domain = self._risk.classify(claim_text)
592
+ has_auth_contra = self._risk.is_authoritative_contra(sources)
593
+ settled_topic = self._settled.is_settled(claim_text)
594
+
595
+ # Step 3: aggregate evidence, apply high-risk multiplier if needed
596
+ score, evidence = self._aggregator.aggregate(sources)
597
+ if risk_domain is not None and score < 0:
598
+ score = round(score * self._cfg.high_risk_multiplier, 4)
599
+ logger.debug("High-risk domain '%s': score amplified to %.3f", risk_domain, score)
600
+
601
+ # Step 4: score β†’ raw decision (tighter threshold for high-risk)
602
+ raw_decision = self._score_to_decision(score, risk_domain)
603
+
604
+ # Step 5: apply safety rules
605
+ final_decision, fired = self._safety.apply(
606
+ raw_decision, score, sources, evidence,
607
+ evidence_state, risk_domain, has_auth_contra,
608
+ )
609
+
610
+ # Step 6: settled-science override β€” any contradicting evidence β†’ REMOVE
611
+ n_con = sum(1 for s in sources if s.stance == "contradicting")
612
+ if (
613
+ settled_topic is not None
614
+ and n_con >= 1
615
+ and int(self._cfg.settled_science_remove) == 1
616
+ ):
617
+ fired.append(
618
+ f"SETTLED_SCIENCE: '{settled_topic}' contradicts scientific consensus "
619
+ f"({n_con} source(s) agree) β€” forcing REMOVE with high confidence"
620
+ )
621
+ final_decision = "remove"
622
+
623
+ # Step 7: compute calibrated confidence
624
+ n_sup = sum(1 for s in sources if s.stance == "supporting")
625
+ n_neu = sum(1 for s in sources if s.stance == "neutral")
626
+ confidence = self._confidence(
627
+ score, sources, evidence_state,
628
+ n_con=n_con, n_sup=n_sup,
629
+ settled_topic=settled_topic,
630
+ )
631
+
632
+ # Step 8: build rationale
633
+ rationale = self._build_rationale(
634
+ claim_text, final_decision, score, confidence,
635
+ n_sup, n_con, n_neu, fired, evidence_state,
636
+ risk_domain, settled_topic,
637
+ )
638
+
639
+ result = StructuredReasoning(
640
+ decision = final_decision,
641
+ evidence_score = score,
642
+ confidence = confidence,
643
+ source_evidence = evidence,
644
+ n_supporting = n_sup,
645
+ n_contradicting = n_con,
646
+ n_neutral = n_neu,
647
+ n_total = len(sources),
648
+ safety_rules_fired = fired,
649
+ risk_domain = risk_domain,
650
+ settled_science_topic = settled_topic,
651
+ rationale = rationale,
652
+ )
653
+
654
+ logger.info(
655
+ "Reasoning: claim='%s…' risk=%s settled=%s state=%s score=%.3f "
656
+ "raw=%s final=%s conf=%s sources=%d(sup=%d/con=%d/neu=%d) rules=%d",
657
+ claim_text[:50], risk_domain or "none", settled_topic or "none",
658
+ evidence_state, score, raw_decision, final_decision, confidence,
659
+ len(sources), n_sup, n_con, n_neu, len(fired),
660
+ )
661
+ return result
662
+
663
+ # ------------------------------------------------------------------
664
+ # Helpers
665
+ # ------------------------------------------------------------------
666
+
667
+ def _score_to_decision(self, score: float, risk_domain: Optional[str] = None) -> str:
668
+ remove_thresh = (
669
+ self._cfg.high_risk_remove_threshold
670
+ if risk_domain is not None
671
+ else self._cfg.remove_threshold
672
+ )
673
+ if score > self._cfg.allow_threshold:
674
+ return "allow"
675
+ if score < remove_thresh:
676
+ return "remove"
677
+ return "flag"
678
+
679
+ def _confidence(
680
+ self,
681
+ score: float,
682
+ sources: List[ScoredSource],
683
+ evidence_state: str,
684
+ n_con: int = 0,
685
+ n_sup: int = 0,
686
+ settled_topic: Optional[str] = None,
687
+ ) -> str:
688
+ """
689
+ Confidence reflects how certain the system is about its decision.
690
+
691
+ Priority cascade (highest wins):
692
+ 1. Settled science + any contra evidence β†’ always "high"
693
+ 2. Consensus scaling: confidence grows with n_contra sources
694
+ base=0.40, +0.12 per source, +0.15 bonus when n_sup=0
695
+ 3. Evidence state shortcuts (strong_contra/strong_support)
696
+ 4. Reliability-weighted fallback
697
+ """
698
+ abs_score = abs(score)
699
+
700
+ if evidence_state == "no_evidence" or not sources:
701
+ return "low"
702
+
703
+ # Rule 1: settled science is always high certainty when contradicted
704
+ if settled_topic is not None and n_con >= 1:
705
+ return "high"
706
+
707
+ # Rule 2: consensus-based scaling
708
+ if n_con >= 1:
709
+ raw = self._cfg.consensus_base_confidence + (n_con - 1) * self._cfg.consensus_per_source_boost
710
+ # Bonus when absolutely no supporting sources exist (no counter-narrative)
711
+ if n_sup == 0:
712
+ raw += self._cfg.no_support_bonus
713
+ raw = min(1.0, raw)
714
+ if raw >= self._cfg.consensus_high_cutoff:
715
+ return "high"
716
+ if raw >= self._cfg.consensus_medium_cutoff:
717
+ return "medium"
718
+ return "low"
719
+
720
+ # Rule 3: conflicting evidence
721
+ if evidence_state == "conflicting":
722
+ return "medium" if abs_score >= 0.3 else "low"
723
+
724
+ # Rule 4: strong supporting state
725
+ if evidence_state == "strong_support" and abs_score >= 0.3:
726
+ return "high"
727
+
728
+ # Rule 5: reliability-weighted fallback
729
+ n_hi_rel = sum(1 for s in sources if s.reliability >= 0.70)
730
+ if n_hi_rel >= 2 and abs_score >= 0.40:
731
+ return "high"
732
+ if n_hi_rel >= 1 and abs_score >= 0.20:
733
+ return "medium"
734
+
735
+ if abs_score >= self._cfg.high_confidence_threshold:
736
+ return "high"
737
+ if abs_score >= self._cfg.low_confidence_threshold:
738
+ return "medium"
739
+ return "low"
740
+
741
+ @staticmethod
742
+ def _build_rationale(
743
+ claim: str,
744
+ decision: str,
745
+ score: float,
746
+ confidence: str,
747
+ n_sup: int,
748
+ n_con: int,
749
+ n_neu: int,
750
+ fired: List[str],
751
+ evidence_state: str = "",
752
+ risk_domain: Optional[str] = None,
753
+ settled_topic: Optional[str] = None,
754
+ ) -> str:
755
+ parts = []
756
+
757
+ # Settled science notice (highest priority label)
758
+ if settled_topic:
759
+ settled_labels = {
760
+ "flat_earth": "flat-Earth (Earth is spherical β€” settled science)",
761
+ "vaccines_autism": "vaccine-autism link (disproven by large-scale studies)",
762
+ "climate_hoax": "climate change denial (97%+ scientific consensus)",
763
+ "evolution_denial": "evolution denial (established biological science)",
764
+ "moon_landing_hoax": "moon landing conspiracy (documented historical fact)",
765
+ "5g_covid": "5G-COVID link (physically impossible β€” settled science)",
766
+ "bleach_cure": "bleach as cure (extremely dangerous pseudoscience)",
767
+ "hiv_not_real": "HIV/AIDS denial (settled medical science)",
768
+ }
769
+ label = settled_labels.get(settled_topic, f"{settled_topic} (settled science)")
770
+ parts.append(f"SETTLED SCIENCE: This claim involves {label}.")
771
+
772
+ elif risk_domain:
773
+ domain_labels = {
774
+ "medical": "medical/health misinformation",
775
+ "harmful_action": "content promoting harmful actions",
776
+ "public_health": "public health misinformation",
777
+ "election": "election integrity misinformation",
778
+ "climate": "climate misinformation",
779
+ }
780
+ label = domain_labels.get(risk_domain, f"{risk_domain} misinformation")
781
+ parts.append(f"HIGH-RISK: This claim involves {label}.")
782
+
783
+ decision_map = {
784
+ "allow": "The claim is supported by available evidence.",
785
+ "flag": "The evidence is insufficient or conflicting β€” the claim is flagged for review.",
786
+ "remove": "Strong contradicting evidence indicates the claim is false or harmful.",
787
+ }
788
+ parts.append(decision_map.get(decision, "Decision made."))
789
+
790
+ state_desc = {
791
+ "strong_contra": "Multiple high-credibility sources contradict this claim.",
792
+ "weak_contra": "Low-credibility sources contradict this claim.",
793
+ "strong_support": "Multiple high-credibility sources support this claim.",
794
+ "weak_support": "Some sources support this claim but their credibility is limited.",
795
+ "conflicting": "Sources are divided β€” some support and some contradict this claim.",
796
+ "neutral_only": "Available sources are neutral and do not confirm or deny the claim.",
797
+ "no_evidence": "No relevant sources were found to assess this claim.",
798
+ }
799
+ if evidence_state in state_desc:
800
+ parts.append(state_desc[evidence_state])
801
+
802
+ # Consensus strength note
803
+ if n_con >= 1 and n_sup == 0:
804
+ parts.append(f"No supporting sources found β€” all {n_con} source(s) contradict the claim.")
805
+ elif n_con >= 3:
806
+ parts.append(f"{n_con} independent sources contradict this claim (strong consensus).")
807
+
808
+ parts.append(
809
+ f"Evidence score: {score:+.3f} ({confidence} confidence). "
810
+ f"Sources: {n_sup} supporting, {n_con} contradicting, {n_neu} neutral."
811
+ )
812
+
813
+ # Safety rules
814
+ if fired:
815
+ parts.append("Safety rules applied: " + " | ".join(fired))
816
+
817
+ return " ".join(parts)
818
+
819
+
820
+ # ─────────────────────────────────────────────────────────────────────────────
821
+ # Convenience: reason over multiple claims, return aggregate decision
822
+ # ──────────────────────��──────────────────────────────────────────────────────
823
+
824
+ def reason_over_claims(
825
+ claim_results: Dict[str, List[ScoredSource]],
826
+ engine: Optional[DeterministicReasoningEngine] = None,
827
+ ) -> Tuple[str, Dict[str, StructuredReasoning]]:
828
+ """
829
+ Run reasoning for each claim independently, then derive a single
830
+ aggregate content decision.
831
+
832
+ Parameters
833
+ ----------
834
+ claim_results : dict mapping claim_text β†’ List[ScoredSource]
835
+ engine : optional pre-built engine (created if None)
836
+
837
+ Returns
838
+ -------
839
+ (aggregate_decision, per_claim_reasoning_dict)
840
+
841
+ Aggregate logic:
842
+ ANY "remove" β†’ aggregate = "remove"
843
+ ANY "flag" β†’ aggregate = "flag" (unless already remove)
844
+ ALL "allow" β†’ aggregate = "allow"
845
+ """
846
+ eng = engine or DeterministicReasoningEngine()
847
+ per_claim: Dict[str, StructuredReasoning] = {}
848
+
849
+ for claim_text, sources in claim_results.items():
850
+ per_claim[claim_text] = eng.reason(claim_text, sources)
851
+
852
+ decisions = [r.decision for r in per_claim.values()]
853
+ if "remove" in decisions:
854
+ agg = "remove"
855
+ elif "flag" in decisions:
856
+ agg = "flag"
857
+ elif decisions:
858
+ agg = "allow"
859
+ else:
860
+ agg = "flag" # no claims β†’ conservative default
861
+
862
+ return agg, per_claim
requirements.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core server
2
+ fastapi>=0.115.0
3
+ uvicorn[standard]>=0.32.0
4
+ pydantic>=2.10.0
5
+
6
+ # HuggingFace Spaces UI
7
+ gradio>=4.44.0
8
+
9
+ # HTTP client (inference.py ServerEnvClient)
10
+ requests>=2.32.0
11
+
12
+ # OpenEnv validator
13
+ openenv-core>=0.2.0
14
+
15
+ # Search backends
16
+ duckduckgo-search==6.3.7
17
+
18
+ # Redis cache (optional, falls back to in-process dict)
19
+ redis==5.2.1
20
+
21
+ # Config
22
+ pyyaml==6.0.2
23
+ python-dotenv==1.0.1
24
+
25
+ # OpenAI-compatible LLM client
26
+ openai>=1.0.0
27
+
28
+ # Lightweight retrieval
29
+ scikit-learn>=1.4.0
30
+ numpy>=1.26.0
retrieval.py ADDED
@@ -0,0 +1,935 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ retrieval.py
3
+ ============
4
+ RAG-style retrieval pipeline for InformationIntegrityEnv.
5
+
6
+ Pipeline:
7
+ 1. QueryRewriter β€” rewrites claims into effective search queries
8
+ 2. RelevanceFilter β€” scores claim↔source semantic similarity
9
+ 3. SourceRanker β€” combines similarity + reliability into a final score
10
+ 4. RetrievalPipeline β€” orchestrates all stages, returns ranked sources only
11
+
12
+ Public helper API (importable standalone):
13
+ rewrite_query(claim) β†’ str
14
+ generate_queries(claim) β†’ List[str]
15
+ filter_relevant_sources(claim, sources) β†’ List[dict]
16
+
17
+ Zero runtime API calls. Fully deterministic given the same inputs.
18
+
19
+ Embedding backend (auto-selected at startup):
20
+ Priority 1 β†’ sentence-transformers (all-MiniLM-L6-v2, fast + accurate)
21
+ Priority 2 β†’ scikit-learn TF-IDF (good for keyword-rich claims)
22
+ Priority 3 β†’ BM25-style BoW cosine (pure stdlib, always available)
23
+
24
+ Configuration via retrieval_config.yaml or environment variables.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import math
31
+ import os
32
+ import re
33
+ import string
34
+ from dataclasses import dataclass, field
35
+ from typing import List, Optional, Tuple
36
+
37
+ import yaml
38
+
39
+ logger = logging.getLogger("retrieval")
40
+
41
+ RETRIEVAL_CONFIG_PATH = os.getenv("RETRIEVAL_CONFIG", "retrieval_config.yaml")
42
+
43
+
44
+ # ─────────────────────────────────────────────────────────────────────────────
45
+ # Config
46
+ # ─────────────────────────────────────────────────────────────────────────────
47
+
48
+ _DEFAULTS = {
49
+ "relevance_threshold": 0.08, # min TF-IDF similarity (bigrams improve precision enough)
50
+ "reliability_threshold": 0.30, # min reliability to keep a source
51
+ "top_k": 6, # max sources returned per claim
52
+ "similarity_weight": 0.55, # weight in combined score
53
+ "reliability_weight": 0.45, # weight in combined score
54
+ "embedding_model": "all-MiniLM-L6-v2",
55
+ "keyword_boost_min_overlap": 2, # keyword overlap to grant a sim boost to high-rel sources
56
+ "keyword_boost_amount": 0.08, # added to sim when high-rel source has sufficient overlap
57
+ "keyword_boost_min_rel": 0.65, # min reliability to receive keyword boost
58
+ }
59
+
60
+
61
+ class RetrievalConfig:
62
+ def __init__(self, path: str = RETRIEVAL_CONFIG_PATH) -> None:
63
+ self._cfg = dict(_DEFAULTS)
64
+ if os.path.exists(path):
65
+ with open(path, "r", encoding="utf-8") as fh:
66
+ loaded = yaml.safe_load(fh) or {}
67
+ self._cfg.update(loaded)
68
+
69
+ def __getattr__(self, name: str):
70
+ if name == "_cfg":
71
+ raise AttributeError(name)
72
+ try:
73
+ return self._cfg[name]
74
+ except KeyError:
75
+ raise AttributeError(f"RetrievalConfig has no attribute '{name}'")
76
+
77
+
78
+ _CFG = RetrievalConfig()
79
+
80
+
81
+ # ─────────────────────────────────────────────────────────────────────────────
82
+ # Data class for a source that has been scored
83
+ # ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ @dataclass
86
+ class ScoredSource:
87
+ id: str
88
+ title: str
89
+ text: str
90
+ url: str
91
+ stance: str
92
+ reliability: float
93
+ source_type: str
94
+ similarity: float = 0.0 # claim↔source semantic similarity [0,1]
95
+ combined: float = 0.0 # final ranking score [0,1]
96
+
97
+ def to_dict(self) -> dict:
98
+ return {
99
+ "id": self.id,
100
+ "title": self.title,
101
+ "text": self.text,
102
+ "url": self.url,
103
+ "stance": self.stance,
104
+ "reliability": round(self.reliability, 4),
105
+ "source_type": self.source_type,
106
+ "similarity": round(self.similarity, 4),
107
+ "combined": round(self.combined, 4),
108
+ }
109
+
110
+
111
+ # ─────────────────────────────────────────────────────────────────────────────
112
+ # Part 1 β€” Query Rewriter
113
+ # ─────────────────────────────────────────────────────────────────────────────
114
+
115
+ # ── Claim type detection ───────────────���─────────────────────────────────────
116
+ _CLAIM_TYPE_PATTERNS = [
117
+ ("scientific_fact", re.compile(
118
+ r"\b\d+\s*Β°[cCfF]\b"
119
+ r"|\b\d+\s*(degrees?|celsius|fahrenheit|kelvin)\b"
120
+ r"|\b(speed|velocity)\s+of\s+(light|sound)\b"
121
+ r"|\b(boil|melt|freez)\w+\s+(point|at)\b"
122
+ r"|\b(atomic|molecular|chemical|formula|element|compound|periodic)\b"
123
+ r"|\bph\s*(of|level|value)\b"
124
+ r"|\b\d+\s*(km|m|cm|mm|kg|g|mg|l|ml|hz|mhz|ghz|nm|km/h|m/s)\b",
125
+ re.IGNORECASE,
126
+ )),
127
+ ("myth", re.compile(
128
+ r"\b(only\s+use|only\s+uses?)\s+\d+\s*(%|percent)"
129
+ r"|\b\d+\s*(%|percent)\s+of\s+(your|our|the|their|human)\s+brain\b"
130
+ r"|\b(flat\s+earth|earth\s+is\s+flat)\b"
131
+ r"|\b(vaccines?\s+(cause|causes?|link|linked)\s+autism)\b"
132
+ r"|\b(5g\s+(cause|spread|create)\w*\s+(covid|virus|cancer))\b"
133
+ r"|\b(bleach\s+cure|drink\s+bleach|inject\s+disinfectant)\b"
134
+ r"|\b(moon\s+landing\s+(fake|hoax|staged))\b"
135
+ r"|\b(evolution\s+is\s+(false|fake|wrong|a\s+lie))\b",
136
+ re.IGNORECASE,
137
+ )),
138
+ ("health", re.compile(
139
+ r"\b(vaccine|vaccination|drug|medication|dose|treatment|cure|cancer|"
140
+ r"covid|virus|bacteria|disease|symptom|clinical|fda|who|cdc|nhs|"
141
+ r"study|trial|efficacy|side\s+effect|immune)\b",
142
+ re.IGNORECASE,
143
+ )),
144
+ ("news_event", re.compile(
145
+ r"\b(government|ministry|president|prime\s+minister|parliament|congress|"
146
+ r"announce[sd]?|pass(?:ed)?|sign(?:ed)?|reform|policy|election|vote|"
147
+ r"january|february|march|april|may|june|july|august|september|"
148
+ r"october|november|december|\b20\d{2}\b)\b",
149
+ re.IGNORECASE,
150
+ )),
151
+ ]
152
+
153
+ # ── Symbol / phrase normalisation ────────────────────────────────────────────
154
+ _SYMBOL_NORMALISE = [
155
+ (re.compile(r"(\d+)\s*Β°\s*[Cc]\b"), r"\1 degrees celsius"),
156
+ (re.compile(r"(\d+)\s*Β°\s*[Ff]\b"), r"\1 degrees fahrenheit"),
157
+ (re.compile(r"(\d+)\s*Β°\s*[Kk]\b"), r"\1 kelvin"),
158
+ (re.compile(r"(\d+)\s*Β°\b"), r"\1 degrees"),
159
+ (re.compile(r"(\d+)\s*%\b"), r"\1 percent"),
160
+ (re.compile(r"\bkm/h\b"), "kilometres per hour"),
161
+ (re.compile(r"\bm/s\b"), "metres per second"),
162
+ (re.compile(r"\bpH\b"), "pH level"),
163
+ (re.compile(r"\bCO2\b"), "carbon dioxide"),
164
+ (re.compile(r"\bH2O\b"), "water"),
165
+ (re.compile(r"\bO2\b"), "oxygen"),
166
+ (re.compile(r"\bNaCl\b"), "sodium chloride salt"),
167
+ ]
168
+
169
+ # ── Type-specific rewrite rules ───────────────────────────────────────────────
170
+ _REWRITE_RULES = [
171
+ ("scientific_fact",
172
+ re.compile(r"^(water|mercury|lead|iron|alcohol|ethanol|nitrogen|oxygen)\s+boils?\s+at\b", re.IGNORECASE),
173
+ lambda m: f"boiling point of {m.group(1).lower()}"),
174
+ ("scientific_fact",
175
+ re.compile(r"^(.*?)\s+melts?\s+at\b", re.IGNORECASE),
176
+ lambda m: f"melting point of {m.group(1).lower()}"),
177
+ ("myth",
178
+ re.compile(r"humans?\s+only\s+use[sd]?\s+(\d+\s*(?:percent|%))\s+of\s+(?:their|the|our|human)?\s*brain", re.IGNORECASE),
179
+ lambda m: f"{m.group(1).replace('%','percent')} brain myth scientific fact"),
180
+ ("myth",
181
+ re.compile(r"(earth|world)\s+is\s+flat", re.IGNORECASE),
182
+ lambda m: f"{m.group(1)} spherical globe scientific proof"),
183
+ ]
184
+
185
+ # ── Negative context keywords (for off-topic filtering) ──────────────────────
186
+ _NEGATIVE_CONTEXT = {
187
+ "scientific_fact": ["reactor", "nuclear", "fission", "steam turbine", "power plant",
188
+ "cooking tip", "recipe for"],
189
+ "myth": [],
190
+ "health": ["fictional", "novel", "movie", "song", "game"],
191
+ "news_event": ["historical", "ancient", "fiction", "fantasy"],
192
+ "default": [],
193
+ }
194
+
195
+ # Fact-check site anchors
196
+ _FACTCHECK_SITES = (
197
+ "site:snopes.com OR site:factcheck.org OR site:politifact.com OR "
198
+ "site:fullfact.org OR site:boomlive.in OR site:altnews.in OR "
199
+ "site:factchecker.in OR site:vishvasnews.com"
200
+ )
201
+
202
+ # Countries for lightweight NER
203
+ _COUNTRIES = [
204
+ "india", "china", "usa", "united states", "uk", "united kingdom", "russia",
205
+ "pakistan", "france", "germany", "japan", "australia", "canada", "brazil",
206
+ "nigeria", "south africa", "italy", "spain", "mexico", "indonesia", "turkey",
207
+ "saudi arabia", "iran", "ukraine", "israel", "egypt", "argentina",
208
+ ]
209
+
210
+ _TOPIC_TEMPLATES = {
211
+ "health": ["{claim}", "fact check {claim}", "is it true {claim} evidence study"],
212
+ "politics": ["{claim}", "fact check {claim}", "{claim} evidence official statement"],
213
+ "technology": ["{claim}", "{claim} scientific evidence", "fact check {claim}"],
214
+ "climate": ["{claim}", "{claim} scientific consensus", "fact check {claim}"],
215
+ "default": ["{claim}", "fact check {claim}", "{claim} true false evidence"],
216
+ }
217
+
218
+ _TOPIC_SIGNALS = {
219
+ "health": r"vaccine|virus|cancer|disease|drug|health|medical|cure|symptom|covid|pandemic",
220
+ "politics": r"government|president|election|congress|policy|law|vote|senator|parliament",
221
+ "technology": r"5g|ai|chip|algorithm|robot|software|data|internet|cyber|phone",
222
+ "climate": r"climate|carbon|emission|warming|fossil|renewable|greenhouse|temperature",
223
+ }
224
+
225
+
226
+ def _classify_claim_type(claim: str) -> str:
227
+ """Return the claim type: scientific_fact | myth | health | news_event | default."""
228
+ for label, pattern in _CLAIM_TYPE_PATTERNS:
229
+ if pattern.search(claim):
230
+ return label
231
+ return "default"
232
+
233
+
234
+ def _normalise_symbols(text: str) -> str:
235
+ """Replace symbols with word forms for better search-engine matching."""
236
+ for pattern, replacement in _SYMBOL_NORMALISE:
237
+ text = pattern.sub(replacement, text)
238
+ return text
239
+
240
+
241
+ class QueryRewriter:
242
+ """
243
+ Rewrites a raw claim string into multiple targeted search queries.
244
+
245
+ Key improvements:
246
+ - Claim type detection (scientific_fact / myth / health / news_event)
247
+ - Symbol normalisation (Β°C β†’ degrees celsius, % β†’ percent)
248
+ - Type-specific rewrite rules (boiling point phrasing, myth framing)
249
+ - 4-variant expansion: factual | verbatim | question-style | fact-check
250
+ """
251
+
252
+ def rewrite(self, claim: str) -> str:
253
+ """
254
+ Return the best single search query for a claim.
255
+
256
+ Steps: strip prefixes β†’ normalise symbols β†’ type-specific rewrite.
257
+ """
258
+ claim = claim.strip().rstrip("?!.")
259
+ claim = re.sub(
260
+ r"^(BREAKING|URGENT|CONFIRMED|SHOCKING|EXCLUSIVE)[:\s!]+",
261
+ "", claim, flags=re.IGNORECASE,
262
+ ).strip()
263
+ claim = re.sub(
264
+ r"^(i heard|they say|apparently|reportedly|rumour has it)[,\s]+",
265
+ "", claim, flags=re.IGNORECASE,
266
+ ).strip()
267
+
268
+ normalised = _normalise_symbols(claim)
269
+ claim_type = _classify_claim_type(claim)
270
+
271
+ for rule_type, pattern, replacement in _REWRITE_RULES:
272
+ if rule_type != claim_type:
273
+ continue
274
+ m = pattern.search(normalised)
275
+ if m:
276
+ prefix = replacement(m)
277
+ tail = normalised[m.end():].strip()
278
+ result = f"{prefix} {tail}".strip()
279
+ logger.debug("QueryRewriter [%s]: '%s' β†’ '%s'", claim_type, claim[:60], result[:60])
280
+ return result
281
+
282
+ return normalised
283
+
284
+ def expand(self, claim: str) -> List[str]:
285
+ """
286
+ Generate up to 4 orthogonal search query variants.
287
+
288
+ Q1 β€” rewritten/factual query (precision for scientific facts & myths)
289
+ Q2 β€” verbatim claim (recall for news articles)
290
+ Q3 β€” question-style (FAQ / encyclopedia pages)
291
+ Q4 β€” fact-check site anchor (explicit debunking/verification)
292
+ """
293
+ cleaned = claim.strip().rstrip("?!.")
294
+ rewritten = self.rewrite(cleaned)
295
+ claim_type = _classify_claim_type(cleaned)
296
+ entities = self._extract_entities(cleaned)
297
+ queries: List[str] = []
298
+
299
+ # Q1: type-specific primary query
300
+ if claim_type == "scientific_fact":
301
+ queries.append(rewritten)
302
+ elif claim_type == "myth":
303
+ base = rewritten if rewritten != cleaned else cleaned
304
+ queries.append(f"{base} myth scientific evidence explanation")
305
+ elif claim_type == "health":
306
+ queries.append(f"{rewritten} clinical evidence study research")
307
+ elif claim_type == "news_event":
308
+ anchors = [e for e in [entities.get("country"), entities.get("date")] if e]
309
+ kw = " ".join(entities.get("keywords", [])[:3])
310
+ queries.append(f'{" ".join(anchors)} official {kw}'.strip() if anchors else rewritten)
311
+ else:
312
+ queries.append(rewritten)
313
+
314
+ # Q2: verbatim (finds news articles that quote the claim directly)
315
+ if cleaned.lower() != queries[0].lower():
316
+ queries.append(cleaned)
317
+
318
+ # Q3: question-style
319
+ question = self._to_question(rewritten, claim_type)
320
+ if question not in queries:
321
+ queries.append(question)
322
+
323
+ # Q4: fact-check site anchor
324
+ fc_query = f'{_FACTCHECK_SITES} {rewritten}'
325
+ if fc_query not in queries:
326
+ queries.append(fc_query)
327
+
328
+ seen: set = set()
329
+ result = []
330
+ for q in queries:
331
+ if q not in seen:
332
+ seen.add(q)
333
+ result.append(q)
334
+ return result[:4]
335
+
336
+ def to_semantic_text(self, claim: str) -> str:
337
+ """
338
+ Produce the text embedded when comparing a claim against a source.
339
+ Repeats key entities so TF-IDF gives them higher weight.
340
+ """
341
+ rewritten = self.rewrite(claim)
342
+ entities = self._extract_entities(claim)
343
+
344
+ parts = [rewritten]
345
+ if entities.get("country"):
346
+ parts.append(entities["country"])
347
+ if entities.get("date"):
348
+ parts.append(entities["date"])
349
+ parts.extend(entities.get("keywords", [])[:3])
350
+ return " ".join(parts)
351
+
352
+ def extract_entities(self, claim: str) -> dict:
353
+ """Public wrapper around _extract_entities."""
354
+ return self._extract_entities(self.rewrite(claim))
355
+
356
+ def _extract_entities(self, text: str) -> dict:
357
+ low = text.lower()
358
+
359
+ country = None
360
+ for c in sorted(_COUNTRIES, key=len, reverse=True):
361
+ if c in low:
362
+ country = c.title()
363
+ break
364
+
365
+ date_match = re.search(
366
+ r'\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|'
367
+ r'jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|'
368
+ r'dec(?:ember)?)[\s.]*\d{1,2}(?:st|nd|rd|th)?\b'
369
+ r'|\b\d{1,2}[\s/-]\d{1,2}[\s/-]\d{2,4}\b'
370
+ r'|\b\d{4}\b',
371
+ text, re.IGNORECASE,
372
+ )
373
+ date = date_match.group(0).strip() if date_match else None
374
+
375
+ qty_match = re.search(
376
+ r'\b\d+(?:\.\d+)?(?:\s*percent|\s*million|\s*billion|\s*crore|\s*lakh)?\b',
377
+ text,
378
+ )
379
+
380
+ _stop = {
381
+ "the","a","an","is","are","was","were","in","on","at","to","from",
382
+ "of","and","or","but","that","this","it","be","will","has","have",
383
+ "its","by","for","not","with","also","into","than","more","about",
384
+ }
385
+ keywords = [
386
+ w for w in re.findall(r'\b[a-zA-Z]{4,}\b', text.lower())
387
+ if w not in _stop
388
+ ][:5]
389
+
390
+ return {
391
+ "country": country,
392
+ "date": date,
393
+ "quantity": qty_match.group(0) if qty_match else None,
394
+ "keywords": keywords,
395
+ }
396
+
397
+ @staticmethod
398
+ def _to_question(rewritten: str, claim_type: str) -> str:
399
+ low = rewritten.lower()
400
+ if claim_type == "scientific_fact":
401
+ if "boiling point" in low or "melting point" in low:
402
+ return f"what is the {rewritten}"
403
+ return f"is it true that {rewritten}"
404
+ if claim_type == "myth":
405
+ return f"is the {rewritten} true or false"
406
+ if claim_type == "health":
407
+ return f"does {rewritten} according to research"
408
+ return f"is it true that {rewritten}"
409
+
410
+ @staticmethod
411
+ def _detect_topic(text: str) -> str:
412
+ low = text.lower()
413
+ for topic, pattern in _TOPIC_SIGNALS.items():
414
+ if re.search(pattern, low):
415
+ return topic
416
+ return "default"
417
+
418
+
419
+ # ─────────────────────────────────────────────────────────────────────────────
420
+ # Part 2 β€” Embedding / Similarity backend (auto-selected)
421
+ # ─────────────────────────────────────────────────────────────────────────────
422
+
423
+ class _EmbeddingBackend:
424
+ """Abstract interface for all similarity backends."""
425
+ name: str = "base"
426
+
427
+ def similarity(self, text_a: str, text_b: str) -> float:
428
+ raise NotImplementedError
429
+
430
+ def batch_similarity(self, query: str, texts: List[str]) -> List[float]:
431
+ return [self.similarity(query, t) for t in texts]
432
+
433
+
434
+ class _SentenceTransformerBackend(_EmbeddingBackend):
435
+ name = "sentence-transformers"
436
+
437
+ def __init__(self, model_name: str) -> None:
438
+ from sentence_transformers import SentenceTransformer
439
+ import numpy as np
440
+ self._model = SentenceTransformer(model_name)
441
+ self._np = np
442
+ logger.info("Embedding backend: sentence-transformers (%s)", model_name)
443
+
444
+ def batch_similarity(self, query: str, texts: List[str]) -> List[float]:
445
+ np = self._np
446
+ all_t = [query] + texts
447
+ embs = self._model.encode(all_t, normalize_embeddings=True, show_progress_bar=False)
448
+ q_emb = embs[0]
449
+ sims = [float(np.dot(q_emb, e)) for e in embs[1:]]
450
+ return [max(0.0, min(1.0, s)) for s in sims]
451
+
452
+ def similarity(self, text_a: str, text_b: str) -> float:
453
+ return self.batch_similarity(text_a, [text_b])[0]
454
+
455
+
456
+ class _TfidfBackend(_EmbeddingBackend):
457
+ name = "tfidf"
458
+
459
+ def __init__(self) -> None:
460
+ from sklearn.feature_extraction.text import TfidfVectorizer
461
+ from sklearn.metrics.pairwise import cosine_similarity as cos_sim
462
+ import numpy as np
463
+ self._Tfidf = TfidfVectorizer
464
+ self._cos = cos_sim
465
+ self._np = np
466
+ logger.info("Embedding backend: scikit-learn TF-IDF")
467
+
468
+ def batch_similarity(self, query: str, texts: List[str]) -> List[float]:
469
+ if not texts:
470
+ return []
471
+ corpus = [query] + texts
472
+ try:
473
+ mat = self._Tfidf(
474
+ stop_words="english",
475
+ sublinear_tf=True, # log(1+tf) β€” reduces noise from repeated words
476
+ ngram_range=(1, 2), # bigrams catch "sunday holiday", "april 5"
477
+ min_df=1, # don't drop rare claim-specific terms
478
+ max_features=8000,
479
+ ).fit_transform(corpus)
480
+ sims = self._cos(mat[0:1], mat[1:]).flatten().tolist()
481
+ return [max(0.0, min(1.0, float(s))) for s in sims]
482
+ except Exception:
483
+ return [0.0] * len(texts)
484
+
485
+ def similarity(self, text_a: str, text_b: str) -> float:
486
+ return self.batch_similarity(text_a, [text_b])[0]
487
+
488
+
489
+ class _BowBackend(_EmbeddingBackend):
490
+ """
491
+ Bag-of-words cosine similarity. Pure stdlib β€” zero extra dependencies.
492
+ Sufficient for keyword-based filtering when better backends are absent.
493
+ """
494
+ name = "bow"
495
+
496
+ def __init__(self) -> None:
497
+ logger.info("Embedding backend: BoW cosine (stdlib fallback)")
498
+ self._stop = {
499
+ "a","an","the","is","it","in","on","at","to","for","of","and",
500
+ "or","but","not","be","are","was","were","has","have","had",
501
+ "this","that","with","from","by","as","about","its","their",
502
+ }
503
+
504
+ def _tokenize(self, text: str) -> dict[str, int]:
505
+ tokens = re.sub(r"[^a-z0-9\s]", " ", text.lower()).split()
506
+ freq: dict[str, int] = {}
507
+ for t in tokens:
508
+ if t not in self._stop and len(t) > 2:
509
+ freq[t] = freq.get(t, 0) + 1
510
+ return freq
511
+
512
+ def similarity(self, text_a: str, text_b: str) -> float:
513
+ fa, fb = self._tokenize(text_a), self._tokenize(text_b)
514
+ if not fa or not fb:
515
+ return 0.0
516
+ vocab = set(fa) | set(fb)
517
+ dot = sum(fa.get(t, 0) * fb.get(t, 0) for t in vocab)
518
+ norm_a = math.sqrt(sum(v * v for v in fa.values()))
519
+ norm_b = math.sqrt(sum(v * v for v in fb.values()))
520
+ if norm_a == 0 or norm_b == 0:
521
+ return 0.0
522
+ return max(0.0, min(1.0, dot / (norm_a * norm_b)))
523
+
524
+ def batch_similarity(self, query: str, texts: List[str]) -> List[float]:
525
+ return [self.similarity(query, t) for t in texts]
526
+
527
+
528
+ def _build_backend(model_name: str) -> _EmbeddingBackend:
529
+ """Auto-select the best available embedding backend."""
530
+ try:
531
+ return _SentenceTransformerBackend(model_name)
532
+ except Exception:
533
+ pass
534
+ try:
535
+ return _TfidfBackend()
536
+ except Exception:
537
+ pass
538
+ return _BowBackend()
539
+
540
+
541
+ # Singleton β€” loaded once at import time
542
+ _BACKEND: Optional[_EmbeddingBackend] = None
543
+
544
+
545
+ def get_backend() -> _EmbeddingBackend:
546
+ global _BACKEND
547
+ if _BACKEND is None:
548
+ _BACKEND = _build_backend(_CFG.embedding_model)
549
+ return _BACKEND
550
+
551
+
552
+ # ─────────────────────────────────────────────────────────────────────────────
553
+ # Part 3 β€” Relevance Filter
554
+ # ─────────────────────────────────────────────────────────────────────────────
555
+
556
+ class RelevanceFilter:
557
+ """
558
+ Scores each source against a claim using semantic similarity.
559
+ Filters out sources below the relevance threshold.
560
+ Also enforces a minimum reliability gate.
561
+ """
562
+
563
+ def __init__(
564
+ self,
565
+ relevance_threshold: float = _CFG.relevance_threshold,
566
+ reliability_threshold: float = _CFG.reliability_threshold,
567
+ ) -> None:
568
+ self._rel_thr = relevance_threshold
569
+ self._crd_thr = reliability_threshold
570
+
571
+ def filter(
572
+ self,
573
+ claim_text: str,
574
+ sources: List[dict],
575
+ rewriter: Optional[QueryRewriter] = None,
576
+ ) -> List[ScoredSource]:
577
+ """
578
+ Returns sources that pass both the relevance and reliability gates,
579
+ each annotated with its similarity score.
580
+
581
+ High-reliability sources receive a keyword-overlap boost.
582
+ Off-topic sources (e.g. "boiling water reactor" for a temperature claim)
583
+ receive a -0.20 sim penalty before the gate is applied.
584
+ """
585
+ if not sources:
586
+ return []
587
+
588
+ rw = rewriter or QueryRewriter()
589
+ query_txt = rw.to_semantic_text(claim_text)
590
+ claim_type = _classify_claim_type(claim_text)
591
+ backend = get_backend()
592
+
593
+ source_texts = [
594
+ s.get("title", "") + " " + s.get("text", "")
595
+ for s in sources
596
+ ]
597
+ similarities = backend.batch_similarity(query_txt, source_texts)
598
+
599
+ # Precompute keyword overlap for the boost
600
+ _stop = {"the","a","an","is","are","was","were","in","on","at","to","from",
601
+ "of","and","or","but","that","this","it","be","will","has","have"}
602
+ claim_kws = {
603
+ w for w in re.findall(r'\b[a-z]{3,}\b', claim_text.lower())
604
+ if w not in _stop
605
+ }
606
+ boost_min_overlap = int(_CFG.keyword_boost_min_overlap)
607
+ boost_amount = float(_CFG.keyword_boost_amount)
608
+ boost_min_rel = float(_CFG.keyword_boost_min_rel)
609
+
610
+ scored = []
611
+ for src, sim in zip(sources, similarities):
612
+ rel = float(src.get("reliability", 0.0))
613
+
614
+ # Keyword boost: high-reliability sources with β‰₯N overlapping claim
615
+ # keywords get a sim boost so they aren't penalised for paraphrasing
616
+ if rel >= boost_min_rel and claim_kws:
617
+ src_kws = {
618
+ w for w in re.findall(r'\b[a-z]{3,}\b',
619
+ (src.get("title","") + " " + src.get("text","")).lower())
620
+ if w not in _stop
621
+ }
622
+ if len(claim_kws & src_kws) >= boost_min_overlap:
623
+ sim = min(1.0, sim + boost_amount)
624
+
625
+ # Off-topic noise penalty: penalise sources whose text contains
626
+ # known negative-context words for this claim type but lacks
627
+ # any claim keyword. This kills "boiling water reactor" matches
628
+ # when the claim is about temperature, "recipe for pasta" etc.
629
+ neg_words = _NEGATIVE_CONTEXT.get(claim_type, [])
630
+ if neg_words:
631
+ src_low = (src.get("title", "") + " " + src.get("text", "")).lower()
632
+ has_neg = any(neg in src_low for neg in neg_words)
633
+ has_claim_kw = bool(claim_kws & {
634
+ w for w in re.findall(r'\b[a-z]{3,}\b', src_low) if w not in _stop
635
+ })
636
+ if has_neg and not has_claim_kw:
637
+ logger.debug(
638
+ "Off-topic penalty applied to '%s'",
639
+ src.get("title", "?")[:50],
640
+ )
641
+ sim = max(0.0, sim - 0.20)
642
+
643
+ # Hard gates
644
+ if sim < self._rel_thr:
645
+ logger.debug(
646
+ "Filtered out '%s' (sim=%.3f < threshold=%.3f)",
647
+ src.get("title", "?")[:50], sim, self._rel_thr,
648
+ )
649
+ continue
650
+ if rel < self._crd_thr:
651
+ logger.debug(
652
+ "Filtered out '%s' (reliability=%.3f < threshold=%.3f)",
653
+ src.get("title", "?")[:50], rel, self._crd_thr,
654
+ )
655
+ continue
656
+
657
+ scored.append(ScoredSource(
658
+ id = src.get("id", ""),
659
+ title = src.get("title", ""),
660
+ text = src.get("text", ""),
661
+ url = src.get("url", ""),
662
+ stance = src.get("stance", "neutral"),
663
+ reliability = rel,
664
+ source_type = src.get("source_type", "unknown"),
665
+ similarity = round(sim, 4),
666
+ ))
667
+
668
+ return scored
669
+
670
+
671
+ # ─────────────────────────────────────────────────────────────────────────────
672
+ # Part 4 β€” Source Ranker
673
+ # ─────────────────────────────────────────────────────────────────────────────
674
+
675
+ class SourceRanker:
676
+ """
677
+ Ranks filtered sources by a combined score:
678
+ combined = w_sim Γ— similarity + w_rel Γ— reliability
679
+
680
+ Sources that are contradicting/supporting receive a stance boost
681
+ because they carry more signal than neutral sources.
682
+ """
683
+
684
+ STANCE_BOOST = {"supporting": 1.10, "contradicting": 1.10, "neutral": 0.80}
685
+
686
+ def __init__(
687
+ self,
688
+ similarity_weight: float = _CFG.similarity_weight,
689
+ reliability_weight: float = _CFG.reliability_weight,
690
+ ) -> None:
691
+ self._w_sim = similarity_weight
692
+ self._w_rel = reliability_weight
693
+
694
+ def rank(
695
+ self,
696
+ sources: List[ScoredSource],
697
+ top_k: int = _CFG.top_k,
698
+ ) -> List[ScoredSource]:
699
+ """Return at most top_k sources sorted by combined score descending."""
700
+ for src in sources:
701
+ boost = self.STANCE_BOOST.get(src.stance, 1.0)
702
+ src.combined = round(
703
+ (self._w_sim * src.similarity + self._w_rel * src.reliability) * boost,
704
+ 4,
705
+ )
706
+
707
+ ranked = sorted(sources, key=lambda s: -s.combined)
708
+ return ranked[:top_k]
709
+
710
+
711
+ # ─────────────────────────────────────────────────────────────────────────────
712
+ # Part 5 β€” Full RetrievalPipeline
713
+ # ─────────────────────────────────────────────────────��───────────────────────
714
+
715
+ class RetrievalPipeline:
716
+ """
717
+ Orchestrates: rewrite β†’ filter β†’ rank β†’ top-k sources per claim.
718
+
719
+ Usage
720
+ -----
721
+ pipeline = RetrievalPipeline()
722
+ ranked = pipeline.retrieve(claim_text="5G causes COVID", sources=scenario_sources)
723
+ # ranked is a List[ScoredSource] β€” relevant only, sorted by quality
724
+ """
725
+
726
+ def __init__(
727
+ self,
728
+ rewriter: Optional[QueryRewriter] = None,
729
+ filt: Optional[RelevanceFilter] = None,
730
+ ranker: Optional[SourceRanker] = None,
731
+ top_k: int = _CFG.top_k,
732
+ ) -> None:
733
+ self._rewriter = rewriter or QueryRewriter()
734
+ self._filter = filt or RelevanceFilter()
735
+ self._ranker = ranker or SourceRanker()
736
+ self._top_k = top_k
737
+
738
+ def retrieve(
739
+ self,
740
+ claim_text: str,
741
+ sources: List[dict],
742
+ top_k: Optional[int] = None,
743
+ ) -> List[ScoredSource]:
744
+ """
745
+ Full pipeline for a single claim against a pool of sources.
746
+
747
+ Returns
748
+ -------
749
+ List[ScoredSource] β€” relevant, ranked, top-k. May be empty if
750
+ nothing passes the relevance/reliability gates.
751
+ """
752
+ k = top_k or self._top_k
753
+ filtered = self._filter.filter(claim_text, sources, self._rewriter)
754
+ ranked = self._ranker.rank(filtered, top_k=k)
755
+
756
+ logger.info(
757
+ "Retrieval: claim='%s…' β†’ %d/%d sources pass gates, top-%d returned",
758
+ claim_text[:50], len(filtered), len(sources), len(ranked),
759
+ )
760
+ return ranked
761
+
762
+ def retrieve_for_all_claims(
763
+ self,
764
+ claims: List[str],
765
+ sources: List[dict],
766
+ top_k: Optional[int] = None,
767
+ ) -> dict[str, List[ScoredSource]]:
768
+ """
769
+ Run retrieval for every claim, return a dict keyed by claim text.
770
+ Sources may appear in multiple claim buckets.
771
+ """
772
+ return {
773
+ claim: self.retrieve(claim, sources, top_k)
774
+ for claim in claims
775
+ }
776
+
777
+
778
+ # ─────────────────────────────────────────────────────────────────────────────
779
+ # Public standalone API
780
+ # Importable directly: from retrieval import rewrite_query, generate_queries,
781
+ # filter_relevant_sources
782
+ # ─────────────────────────────────────────────────────────────────────────────
783
+
784
+ _REWRITER_SINGLETON = QueryRewriter()
785
+
786
+
787
+ def rewrite_query(claim: str) -> str:
788
+ """
789
+ Convert a raw claim into an optimised search query.
790
+
791
+ Handles:
792
+ - Scientific facts β†’ noun-phrase form ("boiling point of water 100 degrees celsius")
793
+ - Myths β†’ debunking frame ("10 percent brain myth scientific fact")
794
+ - News events β†’ entity-anchored (keeps country/date context)
795
+ - Symbols β†’ word form (Β°C β†’ degrees celsius, % β†’ percent)
796
+
797
+ Examples
798
+ --------
799
+ >>> rewrite_query("Water boils at 100Β°C at sea level")
800
+ 'boiling point of water 100 degrees celsius at sea level'
801
+ >>> rewrite_query("Humans only use 10% of their brain")
802
+ '10 percent brain myth scientific fact'
803
+ >>> rewrite_query("India removed Sunday holidays in April")
804
+ 'India removed Sunday holidays in April' # news event β€” kept as-is with normalisation
805
+ """
806
+ return _REWRITER_SINGLETON.rewrite(claim)
807
+
808
+
809
+ def generate_queries(claim: str) -> List[str]:
810
+ """
811
+ Generate 2–4 orthogonal search query variants for a claim.
812
+
813
+ Variant 1 β€” factual / rewritten (best precision for scientific & myth claims)
814
+ Variant 2 β€” verbatim claim (best recall for news articles)
815
+ Variant 3 β€” question-style ("what is the boiling point of water?")
816
+ Variant 4 β€” fact-check anchored ("site:snopes.com OR site:factcheck.org …")
817
+
818
+ Examples
819
+ --------
820
+ >>> generate_queries("Water boils at 100Β°C at sea level")
821
+ [
822
+ 'boiling point of water 100 degrees celsius at sea level',
823
+ 'Water boils at 100Β°C at sea level',
824
+ 'what is the boiling point of water 100 degrees celsius at sea level',
825
+ 'site:snopes.com OR ... boiling point of water ...',
826
+ ]
827
+ """
828
+ return _REWRITER_SINGLETON.expand(claim)
829
+
830
+
831
+ def filter_relevant_sources(
832
+ claim: str,
833
+ sources: List[dict],
834
+ top_k: int = 5,
835
+ relevance_threshold: float = 0.08,
836
+ ) -> List[dict]:
837
+ """
838
+ Filter and rank a list of source dicts by relevance to the claim.
839
+
840
+ Each source dict must contain at least:
841
+ {"title": str, "snippet": str, "url": str}
842
+ Optional keys used if present: "reliability" (float), "stance" (str).
843
+
844
+ Scoring
845
+ -------
846
+ relevance_score = keyword_overlap_ratio (40%)
847
+ + semantic_similarity (60%, via TF-IDF or sentence-transformers)
848
+ - off_topic_penalty (-0.20 if negative-context words found
849
+ but no claim keywords present)
850
+
851
+ Off-topic filter example
852
+ ------------------------
853
+ Claim: "Water boils at 100Β°C at sea level"
854
+ Rejected: "Boiling Water Reactor β€” nuclear power plant design"
855
+ (contains "reactor", "nuclear", no temperature keywords)
856
+ Accepted: "Boiling point of water β€” Wikipedia"
857
+ (contains "boiling", "point", "water", "celsius")
858
+
859
+ Parameters
860
+ ----------
861
+ claim : the raw claim string
862
+ sources : list of source dicts
863
+ top_k : max number of results to return (default 5)
864
+ relevance_threshold: minimum relevance score to keep a source (default 0.08)
865
+
866
+ Returns
867
+ -------
868
+ List of source dicts, each augmented with:
869
+ "relevance_score" : float (0–1, higher = more relevant)
870
+ "keyword_overlap" : int (number of claim keywords found in source)
871
+ Sorted by relevance_score descending, top_k results only.
872
+ """
873
+ if not sources:
874
+ return []
875
+
876
+ claim_type = _classify_claim_type(claim)
877
+ rewritten = _REWRITER_SINGLETON.rewrite(claim)
878
+ backend = get_backend()
879
+
880
+ _stop = {
881
+ "the","a","an","is","are","was","were","in","on","at","to","from",
882
+ "of","and","or","but","that","this","it","be","will","has","have",
883
+ "its","by","for","not","with","also","into","than","more","about",
884
+ }
885
+
886
+ # Claim keywords (from both original and rewritten form)
887
+ claim_kws = {
888
+ w for w in re.findall(r'\b[a-z]{3,}\b', (claim + " " + rewritten).lower())
889
+ if w not in _stop
890
+ }
891
+
892
+ # Normalise sources so both "snippet" and "text" keys work
893
+ normalised = []
894
+ for s in sources:
895
+ text = s.get("snippet") or s.get("text") or ""
896
+ normalised.append({**s, "_body": s.get("title", "") + " " + text})
897
+
898
+ # Semantic similarity scores
899
+ bodies = [s["_body"] for s in normalised]
900
+ sem_scores = backend.batch_similarity(
901
+ _REWRITER_SINGLETON.to_semantic_text(claim), bodies
902
+ )
903
+
904
+ neg_words = _NEGATIVE_CONTEXT.get(claim_type, [])
905
+ results = []
906
+
907
+ for src, sem in zip(normalised, sem_scores):
908
+ body_low = src["_body"].lower()
909
+ body_kws = {w for w in re.findall(r'\b[a-z]{3,}\b', body_low) if w not in _stop}
910
+
911
+ # Keyword overlap ratio
912
+ overlap = len(claim_kws & body_kws)
913
+ max_possible = max(len(claim_kws), 1)
914
+ kw_score = min(1.0, overlap / max_possible)
915
+
916
+ # Combined relevance score
917
+ relevance = 0.40 * kw_score + 0.60 * sem
918
+
919
+ # Off-topic penalty
920
+ if neg_words:
921
+ has_neg = any(neg in body_low for neg in neg_words)
922
+ has_claim_kw = bool(claim_kws & body_kws)
923
+ if has_neg and not has_claim_kw:
924
+ relevance = max(0.0, relevance - 0.20)
925
+
926
+ if relevance < relevance_threshold:
927
+ continue
928
+
929
+ out = {k: v for k, v in src.items() if k != "_body"}
930
+ out["relevance_score"] = round(relevance, 4)
931
+ out["keyword_overlap"] = overlap
932
+ results.append(out)
933
+
934
+ results.sort(key=lambda x: -x["relevance_score"])
935
+ return results[:top_k]
reward.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reward.py
3
+ =========
4
+ Deterministic, itemised reward function for the InformationIntegrity environment.
5
+
6
+ All reward constants are read from REWARD_CONFIG (default: reward_config.yaml)
7
+ so platform teams can tune them without touching Python code.
8
+
9
+ Scoring schema
10
+ --------------
11
+ Process rewards (per step):
12
+ +verify_claim_reward per unique claim verified
13
+ +check_source_reward per unique source checked
14
+ +multi_source_bonus when β‰₯ multi_source_threshold sources consulted
15
+
16
+ Decision reward (episode end):
17
+ +decision_correct decision exactly matches ground truth
18
+ +decision_partial decision is one step away (e.g. flag vs remove)
19
+ 0 decision is fully wrong
20
+
21
+ Penalties:
22
+ premature_decision deciding before min evidence thresholds are met
23
+ redundant_action re-verifying a claim or re-checking a source
24
+ step_inefficiency applied per step beyond efficient_step_threshold
25
+ over_aggression choosing remove when ground truth is allow/flag
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from dataclasses import dataclass, field
32
+ from typing import Dict, Optional
33
+
34
+ import yaml
35
+
36
+ REWARD_CONFIG_PATH = os.getenv("REWARD_CONFIG", "reward_config.yaml")
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Default constants (used when YAML is absent)
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _DEFAULTS: Dict[str, float | int] = {
44
+ # Process
45
+ "verify_claim_reward": 0.20,
46
+ "check_source_reward": 0.20,
47
+ "multi_source_bonus": 0.20,
48
+ "multi_source_threshold": 3, # int: sources needed for bonus
49
+ # Decision
50
+ "decision_correct": 1.00,
51
+ "decision_partial": 0.50,
52
+ "decision_wrong": 0.00,
53
+ # Penalties (negative values)
54
+ "premature_decision": -0.50,
55
+ "redundant_action": -0.10,
56
+ "step_inefficiency": -0.05,
57
+ "over_aggression": -0.30,
58
+ # Clipping
59
+ "min_total_reward": -2.00,
60
+ "max_total_reward": 2.00,
61
+ # Evidence gate
62
+ "min_sources_for_decision": 2, # int
63
+ "min_claims_verified_frac": 0.50, # fraction
64
+ # Efficiency gate
65
+ "efficient_step_threshold": 6, # int: steps before inefficiency kicks in
66
+ }
67
+
68
+ # Decision adjacency: maps (predicted, ground_truth) β†’ partial credit
69
+ _PARTIAL_CREDIT = {
70
+ ("flag", "remove"): True,
71
+ ("remove", "flag"): True,
72
+ ("flag", "allow"): True,
73
+ ("allow", "flag"): True,
74
+ }
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Config loader
79
+ # ---------------------------------------------------------------------------
80
+
81
+ class RewardConfig:
82
+ def __init__(self, path: str = REWARD_CONFIG_PATH) -> None:
83
+ self._cfg = dict(_DEFAULTS)
84
+ if os.path.exists(path):
85
+ with open(path, "r", encoding="utf-8") as fh:
86
+ loaded = yaml.safe_load(fh) or {}
87
+ self._cfg.update(loaded)
88
+
89
+ def __getattr__(self, name: str):
90
+ # Guard: _cfg may not exist yet during unpickling/__init__
91
+ if name == "_cfg":
92
+ raise AttributeError(name)
93
+ try:
94
+ return self._cfg[name]
95
+ except KeyError:
96
+ raise AttributeError(f"RewardConfig has no attribute '{name}'")
97
+
98
+ def as_dict(self) -> dict:
99
+ return dict(self._cfg)
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Episode reward tracker
104
+ # ---------------------------------------------------------------------------
105
+
106
+ @dataclass
107
+ class EpisodeRewardState:
108
+ """
109
+ Mutable accumulator attached to one environment episode.
110
+ Tracks which claims/sources have already received process rewards.
111
+ """
112
+ verified_claims: set[str] = field(default_factory=set)
113
+ checked_sources: set[str] = field(default_factory=set)
114
+ cumulative_reward: float = 0.0
115
+ step_count: int = 0
116
+
117
+ # Itemised totals
118
+ verify_claim_total: float = 0.0
119
+ check_source_total: float = 0.0
120
+ multi_source_bonus_total: float = 0.0
121
+ decision_reward_total: float = 0.0
122
+ premature_penalty_total: float = 0.0
123
+ redundant_penalty_total: float = 0.0
124
+ inefficiency_penalty_total: float = 0.0
125
+ aggression_penalty_total: float = 0.0
126
+
127
+ def reset(self) -> None:
128
+ self.verified_claims = set()
129
+ self.checked_sources = set()
130
+ self.cumulative_reward = 0.0
131
+ self.step_count = 0
132
+ self.verify_claim_total = 0.0
133
+ self.check_source_total = 0.0
134
+ self.multi_source_bonus_total = 0.0
135
+ self.decision_reward_total = 0.0
136
+ self.premature_penalty_total = 0.0
137
+ self.redundant_penalty_total = 0.0
138
+ self.inefficiency_penalty_total = 0.0
139
+ self.aggression_penalty_total = 0.0
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Reward function
144
+ # ---------------------------------------------------------------------------
145
+
146
+ class RewardFunction:
147
+ """
148
+ Stateless reward computer β€” receives episode state + action result,
149
+ returns itemised reward dict and a human-readable message.
150
+ """
151
+
152
+ def __init__(self, config: Optional[RewardConfig] = None) -> None:
153
+ self._cfg = config or RewardConfig()
154
+
155
+ # ------------------------------------------------------------------
156
+ # Public interface
157
+ # ------------------------------------------------------------------
158
+
159
+ def compute_verify_claim(
160
+ self,
161
+ claim_id: str,
162
+ state: EpisodeRewardState,
163
+ ) -> tuple[float, str]:
164
+ """Reward for verifying a claim."""
165
+ if claim_id in state.verified_claims:
166
+ penalty = self._cfg.redundant_action
167
+ state.redundant_penalty_total += penalty
168
+ state.cumulative_reward += penalty
169
+ return penalty, f"Redundant verification of {claim_id} ({penalty:+.2f})"
170
+
171
+ reward = self._cfg.verify_claim_reward
172
+ state.verified_claims.add(claim_id)
173
+ state.verify_claim_total += reward
174
+ state.cumulative_reward += reward
175
+
176
+ # Step inefficiency
177
+ ineff, msg = self._inefficiency(state)
178
+ return reward + ineff, f"Verified claim {claim_id} ({reward:+.2f}){msg}"
179
+
180
+ def compute_check_source(
181
+ self,
182
+ source_id: str,
183
+ state: EpisodeRewardState,
184
+ ) -> tuple[float, str]:
185
+ """Reward for checking a source."""
186
+ if source_id in state.checked_sources:
187
+ penalty = self._cfg.redundant_action
188
+ state.redundant_penalty_total += penalty
189
+ state.cumulative_reward += penalty
190
+ return penalty, f"Redundant source check {source_id} ({penalty:+.2f})"
191
+
192
+ reward = self._cfg.check_source_reward
193
+ state.checked_sources.add(source_id)
194
+ state.check_source_total += reward
195
+ state.cumulative_reward += reward
196
+
197
+ # Multi-source bonus
198
+ bonus = 0.0
199
+ n_sources = len(state.checked_sources)
200
+ threshold = int(self._cfg.multi_source_threshold)
201
+ if n_sources == threshold:
202
+ bonus = self._cfg.multi_source_bonus
203
+ state.multi_source_bonus_total += bonus
204
+ state.cumulative_reward += bonus
205
+
206
+ # Step inefficiency
207
+ ineff, msg = self._inefficiency(state)
208
+ total = reward + bonus + ineff
209
+ bonus_msg = f" +multi-source bonus ({bonus:+.2f})" if bonus else ""
210
+ return total, f"Checked source {source_id} ({reward:+.2f}){bonus_msg}{msg}"
211
+
212
+ def compute_decision(
213
+ self,
214
+ decision: str,
215
+ ground_truth: str,
216
+ state: EpisodeRewardState,
217
+ num_claims: int,
218
+ ) -> tuple[float, str]:
219
+ """Reward/penalty for the final decision action."""
220
+ total = 0.0
221
+ messages = []
222
+
223
+ # ---- Premature decision gate ----
224
+ n_sources = len(state.checked_sources)
225
+ n_verified = len(state.verified_claims)
226
+ min_sources = int(self._cfg.min_sources_for_decision)
227
+ min_verified = self._cfg.min_claims_verified_frac
228
+
229
+ is_premature = (
230
+ n_sources < min_sources
231
+ or (num_claims > 0 and n_verified / num_claims < min_verified)
232
+ )
233
+ if is_premature:
234
+ penalty = self._cfg.premature_decision
235
+ state.premature_penalty_total += penalty
236
+ state.cumulative_reward += penalty
237
+ total += penalty
238
+ messages.append(f"premature decision ({penalty:+.2f})")
239
+
240
+ # ---- Correctness ----
241
+ if decision == ground_truth:
242
+ reward = self._cfg.decision_correct
243
+ messages.append(f"correct decision ({reward:+.2f})")
244
+ elif _PARTIAL_CREDIT.get((decision, ground_truth), False):
245
+ reward = self._cfg.decision_partial
246
+ messages.append(f"partially correct decision ({reward:+.2f})")
247
+ else:
248
+ reward = self._cfg.decision_wrong
249
+ messages.append("wrong decision (0.00)")
250
+
251
+ state.decision_reward_total += reward
252
+ state.cumulative_reward += reward
253
+ total += reward
254
+
255
+ # ---- Over-aggression penalty ----
256
+ if decision == "remove" and ground_truth in ("allow", "flag"):
257
+ penalty = self._cfg.over_aggression
258
+ state.aggression_penalty_total += penalty
259
+ state.cumulative_reward += penalty
260
+ total += penalty
261
+ messages.append(f"over-aggressive moderation ({penalty:+.2f})")
262
+
263
+ return total, "; ".join(messages)
264
+
265
+ def final_score(self, state: EpisodeRewardState) -> float:
266
+ """Return cumulative reward clipped to [min, max]."""
267
+ return float(max(
268
+ self._cfg.min_total_reward,
269
+ min(self._cfg.max_total_reward, state.cumulative_reward),
270
+ ))
271
+
272
+ def breakdown_dict(self, state: EpisodeRewardState) -> dict:
273
+ return {
274
+ "verify_claim_reward": round(state.verify_claim_total, 4),
275
+ "check_source_reward": round(state.check_source_total, 4),
276
+ "multi_source_bonus": round(state.multi_source_bonus_total, 4),
277
+ "decision_reward": round(state.decision_reward_total, 4),
278
+ "premature_penalty": round(state.premature_penalty_total, 4),
279
+ "redundant_penalty": round(state.redundant_penalty_total, 4),
280
+ "inefficiency_penalty": round(state.inefficiency_penalty_total, 4),
281
+ "aggression_penalty": round(state.aggression_penalty_total, 4),
282
+ "cumulative": round(state.cumulative_reward, 4),
283
+ "final_clipped": round(self.final_score(state), 4),
284
+ }
285
+
286
+ # ------------------------------------------------------------------
287
+ # Internal helpers
288
+ # ------------------------------------------------------------------
289
+
290
+ def _inefficiency(self, state: EpisodeRewardState) -> tuple[float, str]:
291
+ threshold = int(self._cfg.efficient_step_threshold)
292
+ if state.step_count > threshold:
293
+ penalty = self._cfg.step_inefficiency
294
+ state.inefficiency_penalty_total += penalty
295
+ state.cumulative_reward += penalty
296
+ return penalty, f" inefficiency ({penalty:+.2f})"
297
+ return 0.0, ""
reward_config.yaml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # reward_config.yaml
2
+ # Tune reward/penalty magnitudes without touching Python code.
3
+ # All values loaded at runtime by reward.py.
4
+
5
+ # Process rewards
6
+ verify_claim_reward: 0.20
7
+ check_source_reward: 0.20
8
+ multi_source_bonus: 0.20
9
+ multi_source_threshold: 3 # int: number of unique sources for bonus
10
+
11
+ # Decision rewards
12
+ decision_correct: 1.00
13
+ decision_partial: 0.50
14
+ decision_wrong: 0.00
15
+
16
+ # Penalties (applied as negative values)
17
+ premature_decision: -0.50
18
+ redundant_action: -0.10
19
+ step_inefficiency: -0.05
20
+ over_aggression: -0.30
21
+
22
+ # Clipping
23
+ min_total_reward: -2.00
24
+ max_total_reward: 2.00
25
+
26
+ # Evidence gate (before decide is allowed without premature penalty)
27
+ min_sources_for_decision: 2
28
+ min_claims_verified_frac: 0.50
29
+
30
+ # Efficiency gate
31
+ efficient_step_threshold: 6 # int: steps before inefficiency penalty kicks in
run_evaluation.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run_evaluation.py
3
+ =================
4
+ Runs inference.py once per scenario across all difficulties and aggregates results.
5
+
6
+ Usage:
7
+ python run_evaluation.py
8
+
9
+ Environment variables forwarded to each inference.py subprocess:
10
+ API_BASE_URL, MODEL_NAME, HF_TOKEN, OPENAI_API_KEY, MAX_STEPS
11
+
12
+ Each subprocess is run as:
13
+ DIFFICULTY=<d> SCENARIO_ID=<id> python inference.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ try:
27
+ from dotenv import load_dotenv
28
+ load_dotenv()
29
+ except ImportError:
30
+ pass
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Config
34
+ # ---------------------------------------------------------------------------
35
+
36
+ DIFFICULTIES = ["easy", "medium", "hard"]
37
+ TASKS_DIR = Path(__file__).parent / "tasks"
38
+ PYTHON = sys.executable # same interpreter that runs this script
39
+ INFERENCE_SCRIPT = Path(__file__).parent / "inference.py"
40
+ TIMEOUT = 180 # seconds per episode (3 min ceiling)
41
+
42
+ # Env vars to forward to subprocesses (all LLM + infra config)
43
+ FORWARD_VARS = [
44
+ "API_BASE_URL", "MODEL_NAME",
45
+ "HF_TOKEN", "OPENAI_API_KEY",
46
+ "MAX_STEPS", "REDIS_URL",
47
+ ]
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Load scenarios
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def load_scenario_ids(difficulty: str) -> list[str]:
55
+ path = TASKS_DIR / difficulty / "scenarios.json"
56
+ with open(path, "r", encoding="utf-8") as f:
57
+ scenarios = json.load(f)
58
+ return [s["id"] for s in scenarios]
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Parse [END] line
63
+ # ---------------------------------------------------------------------------
64
+
65
+ _END_RE = re.compile(
66
+ r"\[END\]\s+"
67
+ r"success=(?P<success>\w+)\s+"
68
+ r"steps=(?P<steps>\d+)\s+"
69
+ r"score=(?P<score>[\d.]+)"
70
+ )
71
+
72
+
73
+ def parse_end_line(output: str) -> Optional[dict]:
74
+ """Extract score/steps/success from [END] line in subprocess stdout."""
75
+ for line in output.splitlines():
76
+ m = _END_RE.search(line)
77
+ if m:
78
+ return {
79
+ "score": float(m.group("score")),
80
+ "steps": int(m.group("steps")),
81
+ "success": m.group("success").lower() == "true",
82
+ }
83
+ return None
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Run one episode
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def run_episode(difficulty: str, scenario_id: str) -> Optional[dict]:
91
+ """
92
+ Invoke inference.py as a subprocess with DIFFICULTY and SCENARIO_ID set.
93
+ Returns parsed result dict or None on failure.
94
+ """
95
+ print(f" Running {scenario_id}...", flush=True)
96
+
97
+ env = {**os.environ}
98
+ env["DIFFICULTY"] = difficulty
99
+ env["SCENARIO_ID"] = scenario_id
100
+
101
+ try:
102
+ proc = subprocess.run(
103
+ [PYTHON, str(INFERENCE_SCRIPT)],
104
+ env=env,
105
+ capture_output=True,
106
+ text=True,
107
+ timeout=TIMEOUT,
108
+ )
109
+ except subprocess.TimeoutExpired:
110
+ print(f" TIMEOUT after {TIMEOUT}s β€” skipping", flush=True)
111
+ return None
112
+ except Exception as exc:
113
+ print(f" ERROR launching subprocess: {exc}", flush=True)
114
+ return None
115
+
116
+ # Print [START]/[STEP]/[END] lines live (already captured, echo them)
117
+ for line in proc.stdout.splitlines():
118
+ if line.startswith(("[START]", "[STEP]", "[END]")):
119
+ print(f" {line}", flush=True)
120
+
121
+ if proc.returncode != 0:
122
+ print(f" FAILED (exit {proc.returncode})", flush=True)
123
+ if proc.stderr:
124
+ # Show last error line only to keep output clean
125
+ last_err = [l for l in proc.stderr.splitlines() if l.strip()]
126
+ if last_err:
127
+ print(f" stderr: {last_err[-1]}", flush=True)
128
+ return None
129
+
130
+ result = parse_end_line(proc.stdout)
131
+ if result is None:
132
+ print(f" WARNING: could not parse [END] line", flush=True)
133
+ return result
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Main evaluation loop
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def run_evaluation() -> dict:
141
+ results: dict[str, list[dict]] = {d: [] for d in DIFFICULTIES}
142
+
143
+ for difficulty in DIFFICULTIES:
144
+ print(f"\n{'='*40}", flush=True)
145
+ print(f"Difficulty: {difficulty.upper()}", flush=True)
146
+ print(f"{'='*40}", flush=True)
147
+
148
+ ids = load_scenario_ids(difficulty)
149
+ for scenario_id in ids:
150
+ ep = run_episode(difficulty, scenario_id)
151
+ if ep is not None:
152
+ ep["scenario_id"] = scenario_id
153
+ results[difficulty].append(ep)
154
+ else:
155
+ results[difficulty].append({
156
+ "scenario_id": scenario_id,
157
+ "score": 0.0,
158
+ "steps": 0,
159
+ "success": False,
160
+ "error": True,
161
+ })
162
+
163
+ return results
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Metrics + summary
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def compute_metrics(episodes: list[dict]) -> dict:
171
+ if not episodes:
172
+ return {"avg_score": 0.0, "avg_steps": 0.0, "success_rate": 0.0, "n": 0}
173
+ n = len(episodes)
174
+ avg_score = sum(e["score"] for e in episodes) / n
175
+ avg_steps = sum(e["steps"] for e in episodes) / n
176
+ success_rate = sum(1 for e in episodes if e["success"]) / n * 100
177
+ return {"avg_score": avg_score, "avg_steps": avg_steps, "success_rate": success_rate, "n": n}
178
+
179
+
180
+ def print_summary(results: dict[str, list[dict]]) -> None:
181
+ metrics = {d: compute_metrics(results[d]) for d in DIFFICULTIES}
182
+
183
+ print("\n" + "=" * 34)
184
+ print(" FINAL RESULTS")
185
+ print("=" * 34)
186
+
187
+ for difficulty in DIFFICULTIES:
188
+ m = metrics[difficulty]
189
+ print(f"\n{difficulty}:")
190
+ print(f" avg_score: {m['avg_score']:.2f}")
191
+ print(f" avg_steps: {m['avg_steps']:.1f}")
192
+ print(f" success_rate: {m['success_rate']:.0f}% ({sum(e['success'] for e in results[difficulty])}/{m['n']})")
193
+
194
+ # Difficulty tier validation
195
+ easy_s = metrics["easy"]["avg_score"]
196
+ medium_s = metrics["medium"]["avg_score"]
197
+ hard_s = metrics["hard"]["avg_score"]
198
+
199
+ print()
200
+ if easy_s > medium_s > hard_s:
201
+ print("Difficulty progression validated βœ“ (easy > medium > hard)")
202
+ else:
203
+ print(
204
+ "WARNING: Difficulty tiers not well separated "
205
+ f"(easy={easy_s:.3f}, medium={medium_s:.3f}, hard={hard_s:.3f})"
206
+ )
207
+ print()
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Entrypoint
212
+ # ---------------------------------------------------------------------------
213
+
214
+ if __name__ == "__main__":
215
+ print("Starting evaluation β€” 15 episodes across easy / medium / hard")
216
+ print(f"Model : {os.getenv('MODEL_NAME', 'gpt-4o')}")
217
+ print(f"API : {os.getenv('API_BASE_URL', 'https://api.openai.com/v1')}")
218
+ print(f"Timeout per episode: {TIMEOUT}s")
219
+
220
+ results = run_evaluation()
221
+ print_summary(results)
server.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server.py
3
+ =========
4
+ FastAPI server β€” exposes InformationIntegrityEnv as an HTTP API.
5
+
6
+ Compatible with:
7
+ - HuggingFace Spaces (gradio or docker)
8
+ - Local Docker deployment
9
+ - Any OpenEnv-compatible evaluation harness
10
+
11
+ Endpoints
12
+ ---------
13
+ POST /reset Start a new episode
14
+ POST /step Submit one action
15
+ GET /state Full internal state (debug)
16
+ POST /collect Collect a new scenario via DataCollector
17
+ GET /health Liveness probe
18
+ GET /scenario Return current scenario metadata (no ground truth)
19
+
20
+ Session management
21
+ ------------------
22
+ Each POST /reset returns a session_id.
23
+ Subsequent /step calls MUST include session_id in the request body.
24
+ Sessions are stored in Redis (TTL = SESSION_TTL_SECONDS).
25
+ Fallback: in-process dict (single-worker mode).
26
+
27
+ Environment variables
28
+ ---------------------
29
+ REDIS_URL (default redis://localhost:6379)
30
+ SESSION_TTL_SECONDS (default 3600)
31
+ HOST (default 0.0.0.0)
32
+ PORT (default 8000)
33
+ LOG_LEVEL (default info)
34
+ """
35
+
36
+ import json
37
+ import logging
38
+ import os
39
+ import pickle
40
+ import traceback
41
+ import uuid
42
+ from contextlib import asynccontextmanager
43
+ from typing import Any, Dict, List, Optional
44
+
45
+ import uvicorn
46
+ from fastapi import Body, FastAPI, HTTPException, Query, Request, status
47
+ from fastapi.middleware.cors import CORSMiddleware
48
+ from fastapi.responses import JSONResponse, RedirectResponse
49
+ from pydantic import BaseModel, Field
50
+
51
+ from data_collector import CollectionRequest, DataCollector
52
+ from environment import InformationIntegrityEnv
53
+ from explainability import ExplainabilityEngine, build_reasoning_from_episode
54
+ from models import Action, ActionType, DecisionType, Observation
55
+
56
+ logger = logging.getLogger("server")
57
+
58
+ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
59
+ SESSION_TTL = int(os.getenv("SESSION_TTL_SECONDS", 3600))
60
+ HOST = os.getenv("HOST", "0.0.0.0")
61
+ PORT = int(os.getenv("PORT", 7860))
62
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "info")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Session store (Redis-backed with in-process fallback)
67
+ # ---------------------------------------------------------------------------
68
+
69
+ class SessionStore:
70
+ def __init__(self) -> None:
71
+ self._mem: Dict[str, bytes] = {}
72
+ self._redis = None
73
+ try:
74
+ import redis
75
+ r = redis.from_url(REDIS_URL)
76
+ r.ping()
77
+ self._redis = r
78
+ logger.info("Session store: Redis at %s", REDIS_URL)
79
+ except Exception as exc:
80
+ logger.warning("Redis unavailable (%s) β€” using in-process session store", exc)
81
+
82
+ def get(self, session_id: str) -> Optional[InformationIntegrityEnv]:
83
+ if self._redis:
84
+ raw = self._redis.get(f"session:{session_id}")
85
+ else:
86
+ raw = self._mem.get(session_id)
87
+ if raw is None:
88
+ return None
89
+ return pickle.loads(raw)
90
+
91
+ def set(self, session_id: str, env: InformationIntegrityEnv) -> None:
92
+ raw = pickle.dumps(env)
93
+ if self._redis:
94
+ self._redis.setex(f"session:{session_id}", SESSION_TTL, raw)
95
+ else:
96
+ self._mem[session_id] = raw
97
+
98
+ def delete(self, session_id: str) -> None:
99
+ if self._redis:
100
+ self._redis.delete(f"session:{session_id}")
101
+ else:
102
+ self._mem.pop(session_id, None)
103
+
104
+ def touch(self, session_id: str) -> None:
105
+ if self._redis:
106
+ self._redis.expire(f"session:{session_id}", SESSION_TTL)
107
+
108
+
109
+ _sessions = SessionStore()
110
+ _collector = DataCollector()
111
+ _explainer = ExplainabilityEngine()
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Request / response schemas
116
+ # ---------------------------------------------------------------------------
117
+
118
+ class ResetRequest(BaseModel):
119
+ # ---- Programmatic difficulty control (OpenEnv eval path) ----
120
+ difficulty: Optional[str] = Field(
121
+ default=None,
122
+ description="Load a deterministic scenario: 'easy' | 'medium' | 'hard'. "
123
+ "Mutually exclusive with `scenario`."
124
+ )
125
+ scenario_id: Optional[Any] = Field(
126
+ default=None,
127
+ description="Scenario index (int) or id (str) within the difficulty dataset. "
128
+ "None = random selection."
129
+ )
130
+ # ---- Legacy / custom scenario (DataCollector output) ----
131
+ scenario: Optional[Dict[str, Any]] = Field(
132
+ default=None,
133
+ description="Pre-collected scenario dict (from POST /collect). "
134
+ "If provided, `difficulty` and `scenario_id` are ignored."
135
+ )
136
+ max_steps: int = Field(default=20, ge=1, le=100)
137
+
138
+
139
+ class ResetResponse(BaseModel):
140
+ session_id: str
141
+ observation: Dict[str, Any]
142
+ message: str = "Episode started"
143
+
144
+
145
+ class StepRequest(BaseModel):
146
+ session_id: str
147
+ action: Dict[str, Any] = Field(
148
+ ...,
149
+ description='e.g. {"type":"verify_claim","claim_id":"claim_001"}'
150
+ )
151
+
152
+
153
+ class StepResponse(BaseModel):
154
+ session_id: str
155
+ observation: Dict[str, Any]
156
+ reward: Dict[str, Any]
157
+ done: bool
158
+ info: Dict[str, Any]
159
+ explanation: Optional[str] = None
160
+
161
+
162
+ class CollectRequest(BaseModel):
163
+ content: str
164
+ claims: List[str] = Field(..., min_length=1)
165
+ difficulty: str = "medium"
166
+ topic: str = ""
167
+ tags: List[str] = Field(default_factory=list)
168
+ force_refresh: bool = False
169
+
170
+
171
+ class CollectResponse(BaseModel):
172
+ scenario: Dict[str, Any]
173
+ cached: bool = False
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # App setup
178
+ # ---------------------------------------------------------------------------
179
+
180
+ @asynccontextmanager
181
+ async def lifespan(app: FastAPI):
182
+ logging.basicConfig(
183
+ level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
184
+ format="%(asctime)s [%(levelname)s] %(name)s β€” %(message)s",
185
+ )
186
+ logger.info("InformationIntegrity env server starting on %s:%s", HOST, PORT)
187
+ yield
188
+ logger.info("Server shutting down")
189
+
190
+
191
+ app = FastAPI(
192
+ title="InformationIntegrityEnv",
193
+ description=(
194
+ "OpenEnv-compatible deterministic benchmark environment for reasoning-based "
195
+ "content moderation.\n\n"
196
+ "Evaluation path: POST /reset with {\"difficulty\": \"easy|medium|hard\"} β€” "
197
+ "no DataCollector or Redis required.\n\n"
198
+ "Custom path: POST /collect then POST /reset with the returned scenario dict."
199
+ ),
200
+ version="1.1.0",
201
+ lifespan=lifespan,
202
+ )
203
+
204
+ app.add_middleware(
205
+ CORSMiddleware,
206
+ allow_origins=["*"],
207
+ allow_methods=["*"],
208
+ allow_headers=["*"],
209
+ )
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Exception handler
214
+ # ---------------------------------------------------------------------------
215
+
216
+ @app.exception_handler(Exception)
217
+ async def global_exception_handler(request: Request, exc: Exception):
218
+ logger.error("Unhandled exception: %s\n%s", exc, traceback.format_exc())
219
+ return JSONResponse(
220
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
221
+ content={"detail": str(exc)},
222
+ )
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Endpoints
227
+ # ---------------------------------------------------------------------------
228
+
229
+ @app.get("/")
230
+ async def root():
231
+ return RedirectResponse(url="/ui/")
232
+
233
+
234
+ @app.get("/health")
235
+ async def health():
236
+ return {"status": "healthy", "service": "InformationIntegrityEnv", "version": "1.1.0"}
237
+
238
+
239
+ @app.get("/metadata")
240
+ async def metadata():
241
+ return {
242
+ "name": "InformationIntegrityEnv",
243
+ "description": (
244
+ "A deterministic, benchmarkable content moderation environment where agents "
245
+ "verify claims, reason over pre-built evidence sources, and make explainable "
246
+ "moderation decisions (allow / flag / remove)."
247
+ ),
248
+ "version": "1.1.0",
249
+ }
250
+
251
+
252
+ @app.get("/schema")
253
+ async def schema():
254
+ return {
255
+ "action": Action.model_json_schema(),
256
+ "observation": Observation.model_json_schema(),
257
+ "state": {
258
+ "type": "object",
259
+ "description": "Full internal environment state (debug only)",
260
+ "properties": {
261
+ "episode_id": {"type": "string"},
262
+ "step_count": {"type": "integer"},
263
+ "difficulty": {"type": "string"},
264
+ "done": {"type": "boolean"},
265
+ "ground_truth": {"type": "string"},
266
+ },
267
+ },
268
+ }
269
+
270
+
271
+ @app.post("/mcp")
272
+ async def mcp(request: Request):
273
+ try:
274
+ body = await request.json()
275
+ except Exception:
276
+ body = {}
277
+ req_id = body.get("id") if isinstance(body, dict) else None
278
+ return {
279
+ "jsonrpc": "2.0",
280
+ "id": req_id,
281
+ "result": {
282
+ "tools": [],
283
+ "description": "Use /reset, /step, /state directly.",
284
+ },
285
+ }
286
+
287
+
288
+ @app.get("/state")
289
+ async def get_state_root():
290
+ """OpenAPI path discovery stub β€” returns empty state when no session specified."""
291
+ return {"status": "no_active_session"}
292
+
293
+
294
+ @app.get("/tasks")
295
+ async def list_tasks():
296
+ """List all available deterministic task datasets with grader metadata."""
297
+ from tasks.registry import TASKS
298
+ from tasks.graders import GraderFactory
299
+ result = []
300
+ for difficulty in ("easy", "medium", "hard"):
301
+ try:
302
+ task = TASKS[difficulty]
303
+ grader = GraderFactory.get(difficulty)
304
+ result.append({
305
+ "id": difficulty,
306
+ "difficulty": difficulty,
307
+ "description": (
308
+ f"Content moderation β€” {difficulty} difficulty. "
309
+ "Agent must verify claims, evaluate sources, and make a moderation decision."
310
+ ),
311
+ "n_scenarios": len(task),
312
+ "scenario_ids": [s["id"] for s in task.get_all_scenarios()],
313
+ "grader": {
314
+ "enabled": True,
315
+ "class": f"tasks.graders.{grader.__class__.__name__}",
316
+ "method": "grade",
317
+ "score_range": [0.0, 1.0]
318
+ },
319
+ "grader_weights": {
320
+ "decision": grader.W_DECISION,
321
+ "evidence": grader.W_EVIDENCE,
322
+ "reasoning": grader.W_REASONING,
323
+ "efficiency": grader.W_EFFICIENCY,
324
+ },
325
+ })
326
+ except Exception as exc:
327
+ result.append({"id": difficulty, "error": str(exc)})
328
+ return result
329
+
330
+
331
+ @app.post("/collect", response_model=CollectResponse)
332
+ async def collect(req: CollectRequest):
333
+ """
334
+ Collect real-world evidence for a claim set and cache the scenario.
335
+ Returns the scenario dict which can be passed directly to /reset.
336
+ """
337
+ request = CollectionRequest(
338
+ content=req.content,
339
+ claims=req.claims,
340
+ difficulty=req.difficulty,
341
+ topic=req.topic,
342
+ tags=req.tags,
343
+ force_refresh=req.force_refresh,
344
+ )
345
+ scenario = _collector.collect(request)
346
+ return CollectResponse(scenario=scenario)
347
+
348
+
349
+ @app.post("/reset", response_model=ResetResponse)
350
+ async def reset(
351
+ req: Optional[ResetRequest] = Body(default=None),
352
+ difficulty: Optional[str] = Query(default=None),
353
+ scenario_id: Optional[str] = Query(default=None),
354
+ ):
355
+ # Default to easy if no body provided OR if body is empty {}
356
+ if req is None or (req.difficulty is None and req.scenario is None):
357
+ req = ResetRequest(difficulty=difficulty or "easy", scenario_id=scenario_id)
358
+ """
359
+ Start a new episode.
360
+
361
+ Query-parameter form (simplest β€” works with curl, browser, any HTTP client):
362
+ POST /reset?difficulty=easy
363
+ POST /reset?difficulty=medium&scenario_id=2
364
+ POST /reset?difficulty=hard&scenario_id=hard_001
365
+
366
+ JSON body form (same fields, body takes precedence):
367
+ POST /reset {"difficulty": "easy"}
368
+ POST /reset {"difficulty": "medium", "scenario_id": 2}
369
+ POST /reset {"scenario": <dict from /collect>} ← legacy / live-data path
370
+ """
371
+ # Query params fill in body fields when the body fields are absent
372
+ effective_difficulty = req.difficulty or difficulty
373
+ effective_scenario_id = req.scenario_id if req.scenario_id is not None else scenario_id
374
+
375
+ # Back-fill so the rest of the handler uses a single pair of variables
376
+ req.difficulty = effective_difficulty
377
+ req.scenario_id = effective_scenario_id
378
+
379
+ # Validate: at least one of difficulty or scenario must be provided
380
+ if not req.difficulty and not req.scenario:
381
+ raise HTTPException(
382
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
383
+ detail=(
384
+ "Provide either 'difficulty' ('easy'|'medium'|'hard') to load a "
385
+ "deterministic scenario, or 'scenario' (dict from POST /collect)."
386
+ ),
387
+ )
388
+
389
+ if req.difficulty and req.difficulty not in ("easy", "medium", "hard"):
390
+ raise HTTPException(
391
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
392
+ detail=f"Invalid difficulty '{req.difficulty}'. Must be easy, medium, or hard.",
393
+ )
394
+
395
+ session_id = str(uuid.uuid4())
396
+ try:
397
+ if req.scenario:
398
+ # Legacy path β€” caller supplies a pre-collected scenario dict
399
+ env = InformationIntegrityEnv(
400
+ scenario=req.scenario,
401
+ max_steps=req.max_steps,
402
+ )
403
+ obs = env.reset()
404
+ else:
405
+ # Eval path β€” load from static dataset (no DataCollector)
406
+ env = InformationIntegrityEnv.from_dataset(
407
+ difficulty=req.difficulty,
408
+ scenario_id=req.scenario_id,
409
+ max_steps=req.max_steps,
410
+ )
411
+ obs = env.reset()
412
+ except (KeyError, IndexError, FileNotFoundError) as exc:
413
+ raise HTTPException(
414
+ status_code=status.HTTP_404_NOT_FOUND,
415
+ detail=f"Scenario not found: {exc}",
416
+ )
417
+ except Exception as exc:
418
+ raise HTTPException(
419
+ status_code=status.HTTP_400_BAD_REQUEST,
420
+ detail=f"Failed to create environment: {exc}",
421
+ )
422
+
423
+ _sessions.set(session_id, env)
424
+ return ResetResponse(
425
+ session_id=session_id,
426
+ observation=obs.model_dump(),
427
+ )
428
+
429
+
430
+ @app.post("/step", response_model=StepResponse)
431
+ async def step(req: StepRequest):
432
+ """
433
+ Submit one action and advance the episode.
434
+ """
435
+ env = _sessions.get(req.session_id)
436
+ if env is None:
437
+ raise HTTPException(
438
+ status_code=status.HTTP_404_NOT_FOUND,
439
+ detail=f"Session '{req.session_id}' not found or expired.",
440
+ )
441
+
442
+ try:
443
+ action = Action(**req.action)
444
+ except Exception as exc:
445
+ raise HTTPException(
446
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
447
+ detail=f"Invalid action: {exc}",
448
+ )
449
+
450
+ try:
451
+ obs, reward, done, info = env.step(action)
452
+ except RuntimeError as exc:
453
+ raise HTTPException(
454
+ status_code=status.HTTP_409_CONFLICT,
455
+ detail=str(exc),
456
+ )
457
+
458
+ # Persist updated env
459
+ _sessions.set(req.session_id, env)
460
+ _sessions.touch(req.session_id)
461
+
462
+ # Build explanation and attach grader_score when episode ends
463
+ explanation: Optional[str] = None
464
+ if done:
465
+ # Compute grader score and attach to info so the platform can verify it
466
+ try:
467
+ ep_result = env.episode_result()
468
+ info["grader_score"] = ep_result.grader_score
469
+ info["episode_result"] = ep_result.model_dump()
470
+ except Exception as exc:
471
+ logger.warning("grader_score computation failed: %s", exc)
472
+ info.setdefault("grader_score", 0.005)
473
+
474
+ try:
475
+ decision = info.get("decision") or req.action.get("decision")
476
+ reasoning = build_reasoning_from_episode(
477
+ obs=obs,
478
+ decision=decision or "none",
479
+ episode_id=env._episode_id,
480
+ difficulty=env.scenario.difficulty,
481
+ )
482
+ explanation = _explainer.generate_explanation(reasoning)
483
+ except Exception as exc:
484
+ logger.warning("Explanation generation failed: %s", exc)
485
+
486
+ return StepResponse(
487
+ session_id=req.session_id,
488
+ observation=obs.model_dump(),
489
+ reward=reward.model_dump(),
490
+ done=done,
491
+ info=info,
492
+ explanation=explanation,
493
+ )
494
+
495
+
496
+ @app.get("/state/{session_id}")
497
+ async def get_state(session_id: str):
498
+ """Return full internal environment state (debug/audit endpoint)."""
499
+ env = _sessions.get(session_id)
500
+ if env is None:
501
+ raise HTTPException(status_code=404, detail="Session not found")
502
+ return env.state()
503
+
504
+
505
+ @app.get("/scenario/{session_id}")
506
+ async def get_scenario(session_id: str):
507
+ """Return scenario metadata without ground truth (safe for agent use)."""
508
+ env = _sessions.get(session_id)
509
+ if env is None:
510
+ raise HTTPException(status_code=404, detail="Session not found")
511
+ sc = env.scenario
512
+ return {
513
+ "id": sc.id,
514
+ "difficulty": sc.difficulty,
515
+ "topic": sc.topic,
516
+ "tags": sc.tags,
517
+ "content": sc.content,
518
+ "n_claims": len(sc.claims),
519
+ "n_sources": len(sc.sources),
520
+ "claim_ids": [c.id for c in sc.claims],
521
+ "source_ids": [s.id for s in sc.sources],
522
+ }
523
+
524
+
525
+ @app.delete("/session/{session_id}")
526
+ async def delete_session(session_id: str):
527
+ """Explicitly end a session and free memory."""
528
+ _sessions.delete(session_id)
529
+ return {"status": "deleted", "session_id": session_id}
530
+
531
+
532
+ class GradeRequest(BaseModel):
533
+ """Request body for the /grade endpoint."""
534
+ task_id: str = Field(..., description="Task difficulty: easy, medium, or hard")
535
+ episode: Optional[dict] = Field(default=None, description="Episode data to grade")
536
+
537
+
538
+ @app.post("/grade")
539
+ async def grade_episode(req: GradeRequest):
540
+ """
541
+ Grade a completed episode for a specific task.
542
+ This endpoint allows the OpenEnv validator to test graders.
543
+ """
544
+ from tasks.graders import GraderFactory
545
+
546
+ try:
547
+ grader = GraderFactory.get(req.task_id)
548
+ score = grader.grade(req.episode or {})
549
+
550
+ return {
551
+ "task_id": req.task_id,
552
+ "score": score,
553
+ "score_range": [0.0, 1.0],
554
+ "grader": f"tasks.graders.{grader.__class__.__name__}"
555
+ }
556
+ except ValueError as e:
557
+ raise HTTPException(status_code=400, detail=str(e))
558
+ except Exception as e:
559
+ logger.error("Grading failed: %s", traceback.format_exc())
560
+ raise HTTPException(status_code=500, detail=f"Grading failed: {str(e)}")
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # Entrypoint
565
+ # ---------------------------------------------------------------------------
566
+
567
+ if __name__ == "__main__":
568
+ uvicorn.run(
569
+ "server:app",
570
+ host=HOST,
571
+ port=PORT,
572
+ log_level=LOG_LEVEL,
573
+ reload=False,
574
+ )
server/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/__init__.py
3
+ ==================
4
+ Makes `server` a package while still exposing the FastAPI `app`
5
+ defined in the root-level server.py.
6
+
7
+ We use importlib to load server.py by absolute path, avoiding the
8
+ circular-name clash that would occur with a plain `import server`.
9
+ """
10
+
11
+ import importlib.util
12
+ import os
13
+
14
+ _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+ _spec = importlib.util.spec_from_file_location(
16
+ "_server_root",
17
+ os.path.join(_root, "server.py"),
18
+ )
19
+ _mod = importlib.util.module_from_spec(_spec)
20
+ _spec.loader.exec_module(_mod)
21
+
22
+ app = _mod.app # re-export for `from server import app`
server/app.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ server/app.py
3
+ =============
4
+ Entry point for multi-mode deployment (uv run server / python -m server.app).
5
+
6
+ Loads the FastAPI app from the root-level server.py via importlib so the
7
+ `server` package name does not shadow the root module, and exposes a
8
+ main() function for the [project.scripts] console script entry point.
9
+ """
10
+
11
+ import importlib.util
12
+ import os
13
+ import sys
14
+
15
+ import uvicorn
16
+
17
+ # Load root server.py by absolute path to avoid circular package import
18
+ _root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
+ _spec = importlib.util.spec_from_file_location(
20
+ "_server_root",
21
+ os.path.join(_root, "server.py"),
22
+ )
23
+ _mod = importlib.util.module_from_spec(_spec)
24
+ # Ensure the root is on sys.path so root-level imports inside server.py work
25
+ if _root not in sys.path:
26
+ sys.path.insert(0, _root)
27
+ _spec.loader.exec_module(_mod)
28
+
29
+ app = _mod.app # re-export for ASGI runners (e.g. uvicorn server.app:app)
30
+
31
+
32
+ def main(host: str = "0.0.0.0", port: int = 8000):
33
+ """Console script entry point: uv run server / python -m server.app."""
34
+ host = os.getenv("HOST", host)
35
+ port = int(os.getenv("PORT", port))
36
+ log_level = os.getenv("LOG_LEVEL", "info")
37
+ uvicorn.run(app, host=host, port=port, log_level=log_level)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ import argparse
42
+
43
+ parser = argparse.ArgumentParser()
44
+ parser.add_argument("--host", default="0.0.0.0")
45
+ parser.add_argument("--port", type=int, default=8000)
46
+ args = parser.parse_args()
47
+ main(host=args.host, port=args.port)
stance_classifier.py ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ stance_classifier.py
3
+ ====================
4
+ Three-layer stance classification for source ↔ claim pairs.
5
+
6
+ Layer 1 β€” Semantic Entailment Heuristics (always runs, zero cost)
7
+ Detects indirect contradictions and supports that keyword lists miss.
8
+ Example: "Earth is round" contradicts "Earth is flat" via antonym matching.
9
+
10
+ Layer 2 β€” Expanded Keyword Matching (always runs, zero cost)
11
+ Larger keyword lists, threshold=1 for strong signals (false, debunked, etc.)
12
+ Falls back on Layer 1 result if ambiguous.
13
+
14
+ Layer 3 β€” facebook/bart-large-mnli NLI (completely free, no API key needed)
15
+ Natural Language Inference: premise=source_text, hypothesis=claim.
16
+ ENTAILMENT β†’ SUPPORTS
17
+ CONTRADICTION β†’ CONTRADICTS
18
+ NEUTRAL β†’ NEUTRAL
19
+
20
+ Primary: HF Inference API (set HF_TOKEN in .env for higher rate limits;
21
+ works without token on public models β€” just slower)
22
+ Fallback: local `transformers` pipeline (pip install transformers torch)
23
+
24
+ Cached in-process β€” same (claim, source) pair never classified twice.
25
+
26
+ Priority: NLI result > L1 result > L2 result > neutral
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import logging
33
+ import os
34
+ import re
35
+ from typing import Dict, Optional, Tuple
36
+
37
+ logger = logging.getLogger("stance_classifier")
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Layer 1 β€” Semantic Entailment Heuristics
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ # Pairs of (claim_pattern β†’ contradicting_signal) for common flat-earth-style claims
44
+ # Each entry: (regex that matches the CLAIM, regex that in the SOURCE indicates contradiction)
45
+ _ENTAILMENT_CONTRA_RULES: list[tuple[str, str]] = [
46
+ # Shape / science denial
47
+ (r"\bflat\s+earth\b|\bearth\s+is\s+flat\b",
48
+ r"\bglobe\b|\bspherical\b|\bround\b|\borbits\b|\bnasa\b|\bscientists\b"),
49
+ # Vaccine safety
50
+ (r"\bvaccine.{0,20}(cause|causes|cause[sd]|causing).{0,20}(autism|dna|death|cancer|infertil)",
51
+ r"\bno\s+evidence\b|\bdebunked\b|\bsafe\b|\bclinical\s+trial\b|\bfda\s+approved\b"),
52
+ # 5G / radiation
53
+ (r"\b5g.{0,30}(spread|cause|transmit|covid|virus|cancer|radiation)",
54
+ r"\bno\s+link\b|\bno\s+evidence\b|\bdebunked\b|\bscientifically\b|\bimpossible\b"),
55
+ # Climate denial
56
+ (r"\bclimate\s+change\s+is\s+(fake|hoax|not\s+real)",
57
+ r"\bscientific\s+consensus\b|\bdata\s+shows\b|\bglobal\s+warming\b|\b97\s*%\b"),
58
+ # Election / fraud
59
+ (r"\belection.{0,20}(stolen|rigged|fraud|fake)",
60
+ r"\bno\s+evidence\b|\bcourts?\s+reject\b|\baudit\b.*\bno\s+fraud\b|\bofficial\s+results?\b"),
61
+ # General "X never happened"
62
+ (r"\b(never\s+happened|didn.t\s+happen|is\s+a\s+hoax|is\s+(fake|fabricated))",
63
+ r"\bconfirmed\b|\bverified\b|\bofficial\s+records?\b|\bdocumented\b"),
64
+ ]
65
+
66
+ # claim_pattern β†’ source_pattern for SUPPORTING signal
67
+ _ENTAILMENT_SUPPORT_RULES: list[tuple[str, str]] = [
68
+ # Vaccine efficacy
69
+ (r"\bvaccine.{0,20}(effective|efficacy|prevent|protect)",
70
+ r"\bclinical\s+trial\b|\befficacy\b|\bphase\s+[23]\b|\bfda\b|\bwho\b"),
71
+ # Climate consensus
72
+ (r"\bclimate\s+change\s+is\s+real\b|\bglobal\s+warming\b",
73
+ r"\bscientific\s+consensus\b|\b97\s*%\b|\bipcc\b|\bnasa\b|\bnoaa\b"),
74
+ # Official announcements
75
+ (r"\b(government|ministry|official).{0,30}(announce|confirm|pass|enact)",
76
+ r"\bofficial\b|\bgovernment\b|\bministry\b|\bgazette\b|\bpib\b|\bofficial\s+statement\b"),
77
+ ]
78
+
79
+ # Antonym pairs: if the claim contains word A and source contains word B (or vice versa)
80
+ # in the same context window, it's likely a contradiction
81
+ _ANTONYM_PAIRS: list[tuple[str, str]] = [
82
+ ("flat", "spherical"), ("flat", "globe"), ("flat", "round"),
83
+ ("cure", "no cure"), ("safe", "dangerous"), ("safe", "unsafe"),
84
+ ("effective", "ineffective"), ("effective", "no evidence"),
85
+ ("real", "fake"), ("real", "hoax"), ("true", "false"),
86
+ ("confirmed", "unconfirmed"), ("proven", "disproven"),
87
+ ("increases", "decreases"), ("causes", "does not cause"),
88
+ ("linked", "no link"), ("linked", "unrelated"),
89
+ ("harmful", "harmless"), ("harmful", "safe"),
90
+ ]
91
+
92
+
93
+ class SemanticHeuristicLayer:
94
+ """
95
+ Pattern-matching entailment: catches indirect stances that keyword
96
+ lists miss because the source paraphrases rather than explicitly labels.
97
+ """
98
+
99
+ def classify(self, claim: str, source_text: str) -> Optional[str]:
100
+ """
101
+ Returns 'supporting' | 'contradicting' | None.
102
+ None means this layer has no signal β€” defer to next layer.
103
+ """
104
+ claim_low = claim.lower()
105
+ source_low = source_text.lower()
106
+
107
+ # Check contradiction entailment rules
108
+ for claim_pat, source_pat in _ENTAILMENT_CONTRA_RULES:
109
+ if re.search(claim_pat, claim_low) and re.search(source_pat, source_low):
110
+ logger.debug("SemanticHeuristic CONTRA rule matched: claim_pat=%s", claim_pat[:40])
111
+ return "contradicting"
112
+
113
+ # Check support entailment rules
114
+ for claim_pat, source_pat in _ENTAILMENT_SUPPORT_RULES:
115
+ if re.search(claim_pat, claim_low) and re.search(source_pat, source_low):
116
+ logger.debug("SemanticHeuristic SUPPORT rule matched: claim_pat=%s", claim_pat[:40])
117
+ return "supporting"
118
+
119
+ # Antonym detection: extract key noun/adj from claim, look for antonym in source
120
+ for word_a, word_b in _ANTONYM_PAIRS:
121
+ if word_a in claim_low and word_b in source_low:
122
+ return "contradicting"
123
+ if word_b in claim_low and word_a in source_low:
124
+ return "contradicting"
125
+
126
+ return None # no signal
127
+
128
+
129
+ # ─────────────────────────────────────────────────────────────────────────────
130
+ # Layer 2 β€” Expanded Keyword Matching
131
+ # ─────────────────────────────────────────────────────────────────────────────
132
+
133
+ # Strong single-hit contradicting signals (threshold=1)
134
+ _STRONG_CONTRA = {
135
+ "false", "debunked", "disproven", "misinformation", "misleading",
136
+ "no evidence", "lacks evidence", "unfounded", "refuted", "myth",
137
+ "conspiracy theory", "fact-check", "factcheck", "incorrect",
138
+ "not true", "inaccurate", "unproven", "pseudoscience", "hoax",
139
+ "wrong", "fake", "fabricated", "baseless", "discredited",
140
+ }
141
+
142
+ # Weak contradicting signals (threshold=2 β€” need two hits)
143
+ _WEAK_CONTRA = {
144
+ "disputed", "controversial", "unverified", "claims", "alleged",
145
+ "questioned", "skeptics", "critics", "challenged", "contradicts",
146
+ "refutes", "rejects", "warns against", "cautioned",
147
+ }
148
+
149
+ # Strong single-hit supporting signals (threshold=1)
150
+ _STRONG_SUPPORT = {
151
+ "confirms", "confirmed", "verified", "validated", "proven",
152
+ "evidence shows", "data shows", "research shows", "study finds",
153
+ "scientists confirm", "officially announced", "official statement",
154
+ "press release", "gazette notification", "government confirms",
155
+ "approved", "endorsed", "supported by evidence",
156
+ }
157
+
158
+ # Weak supporting signals (threshold=2)
159
+ _WEAK_SUPPORT = {
160
+ "suggests", "indicates", "shows", "demonstrates", "according to",
161
+ "reports", "states", "announced", "published", "found that",
162
+ }
163
+
164
+
165
+ class KeywordLayer:
166
+ """
167
+ Expanded keyword matching with differentiated strong/weak thresholds.
168
+ Threshold for strong signals = 1 (any single hit is decisive).
169
+ Threshold for weak signals = 2 (need corroboration).
170
+ """
171
+
172
+ def classify(self, text: str) -> Tuple[str, float]:
173
+ """
174
+ Returns (stance, confidence).
175
+ stance = 'supporting' | 'contradicting' | 'neutral'
176
+ confidence = 0.0–1.0 (how certain this layer is)
177
+ """
178
+ low = text.lower()
179
+
180
+ strong_contra = sum(1 for kw in _STRONG_CONTRA if kw in low)
181
+ weak_contra = sum(1 for kw in _WEAK_CONTRA if kw in low)
182
+ strong_supp = sum(1 for kw in _STRONG_SUPPORT if kw in low)
183
+ weak_supp = sum(1 for kw in _WEAK_SUPPORT if kw in low)
184
+
185
+ contra_score = strong_contra * 2 + weak_contra
186
+ supp_score = strong_supp * 2 + weak_supp
187
+
188
+ # Strong single signal is decisive
189
+ if strong_contra >= 1 and strong_contra >= strong_supp:
190
+ conf = min(1.0, 0.6 + 0.1 * strong_contra)
191
+ return "contradicting", conf
192
+
193
+ if strong_supp >= 1 and strong_supp >= strong_contra:
194
+ conf = min(1.0, 0.6 + 0.1 * strong_supp)
195
+ return "supporting", conf
196
+
197
+ # Weak signals need 2+
198
+ if contra_score >= 2 and contra_score > supp_score:
199
+ return "contradicting", 0.45
200
+
201
+ if supp_score >= 2 and supp_score > contra_score:
202
+ return "supporting", 0.45
203
+
204
+ return "neutral", 0.20
205
+
206
+
207
+ # ─────────────────────────────────────────────────────────────────────────────
208
+ # Layer 3 β€” facebook/bart-large-mnli (NLI, completely free)
209
+ # ─────────────────────────────────────────────────────────────────────────────
210
+
211
+ _HF_API_URL = "https://router.huggingface.co/hf-inference/models/facebook/bart-large-mnli/v1/zero-shot-classification"
212
+ _NLI_ENABLED = os.getenv("STANCE_NLI_ENABLED", "true").lower() != "false"
213
+
214
+ # Label mapping: HuggingFace returns "ENTAILMENT", "CONTRADICTION", "NEUTRAL"
215
+ _NLI_LABEL_MAP = {
216
+ "entailment": "supporting",
217
+ "contradiction": "contradicting",
218
+ "neutral": "neutral",
219
+ }
220
+
221
+
222
+ def classify_stance(claim: str, evidence: str) -> dict:
223
+ """
224
+ Public NLI helper used by tests / external callers.
225
+
226
+ premise = evidence (the source text)
227
+ hypothesis = claim (what we are verifying)
228
+
229
+ Returns:
230
+ {"stance": "SUPPORTS" | "CONTRADICTS" | "NEUTRAL", "confidence": float}
231
+ """
232
+ result = _bart_nli(claim=claim, text=evidence[:512], title="")
233
+ if result is None:
234
+ return {"stance": "NEUTRAL", "confidence": 0.20}
235
+ stance, confidence = result
236
+ return {
237
+ "stance": {
238
+ "supporting": "SUPPORTS",
239
+ "contradicting": "CONTRADICTS",
240
+ "neutral": "NEUTRAL",
241
+ }.get(stance, "NEUTRAL"),
242
+ "confidence": confidence,
243
+ }
244
+
245
+
246
+ def _bart_nli(claim: str, title: str, text: str) -> Optional[Tuple[str, float]]:
247
+ """
248
+ Core NLI call. Returns (stance_internal, confidence) or None on failure.
249
+ stance_internal = "supporting" | "contradicting" | "neutral"
250
+
251
+ Strategy
252
+ --------
253
+ 1. Try HF Inference API (zero-install, rate-limited but free)
254
+ 2. Try local `transformers` pipeline (no rate limit, needs torch)
255
+ 3. Return None β†’ caller falls back to keyword result
256
+ """
257
+ combined = f"{title} {text}".strip()[:512]
258
+ if not combined:
259
+ return None
260
+
261
+ # ── attempt 1: HF Inference API ──────────────────────────────────────────
262
+ result = _hf_api_nli(claim=claim, premise=combined)
263
+ if result is not None:
264
+ return result
265
+
266
+ # ── attempt 2: local transformers ────────────────────────────────────────
267
+ result = _local_nli(claim=claim, premise=combined)
268
+ return result # None if both failed
269
+
270
+
271
+ # ── HF Inference API ─────────────────────────────────────────────────────────
272
+
273
+ def _hf_api_nli(claim: str, premise: str) -> Optional[Tuple[str, float]]:
274
+ """
275
+ Calls the HF router zero-shot classification endpoint.
276
+ Requires HF_TOKEN β€” set it in .env for free access.
277
+ Without a token, skips to local transformers.
278
+ Returns (stance, confidence) or None.
279
+ """
280
+ try:
281
+ import requests as _req
282
+ except ImportError:
283
+ return None
284
+
285
+ hf_token = os.getenv("HF_TOKEN", "")
286
+ if not hf_token or hf_token.startswith("your_"):
287
+ logger.debug("No HF_TOKEN set β€” skipping HF router API, using local transformers")
288
+ return None
289
+
290
+ headers = {
291
+ "Content-Type": "application/json",
292
+ "Authorization": f"Bearer {hf_token}",
293
+ }
294
+
295
+ # router.huggingface.co zero-shot-classification format:
296
+ # inputs = the text to classify (premise)
297
+ # parameters.candidate_labels = the hypothesis options
298
+ # multi_label=False β†’ softmax across labels (NLI-style)
299
+ nli_payload = {
300
+ "inputs": premise,
301
+ "parameters": {
302
+ "candidate_labels": ["supports the claim", "contradicts the claim", "unrelated to the claim"],
303
+ "hypothesis_template": "{}.",
304
+ "multi_label": False,
305
+ },
306
+ }
307
+
308
+ try:
309
+ resp = _req.post(_HF_API_URL, headers=headers, json=nli_payload, timeout=15)
310
+ if resp.status_code in (503, 502):
311
+ # Model loading β€” skip silently, fall through to local
312
+ logger.debug("HF API: model loading (%s), falling back to local", resp.status_code)
313
+ return None
314
+ if not resp.ok:
315
+ logger.debug("HF API error %s: %s", resp.status_code, resp.text[:120])
316
+ return None
317
+
318
+ data = resp.json()
319
+ return _parse_hf_router_response(data)
320
+
321
+ except Exception as exc:
322
+ logger.debug("HF API NLI call failed: %s", exc)
323
+ return None
324
+
325
+
326
+ def _parse_hf_router_response(data) -> Optional[Tuple[str, float]]:
327
+ """
328
+ Parse the HuggingFace router zero-shot-classification response.
329
+
330
+ Expected shape:
331
+ {
332
+ "sequence": "...",
333
+ "labels": ["supports the claim", "contradicts the claim", "unrelated to the claim"],
334
+ "scores": [0.85, 0.10, 0.05]
335
+ }
336
+ """
337
+ if not data or not isinstance(data, dict):
338
+ return None
339
+
340
+ labels = data.get("labels", [])
341
+ scores = data.get("scores", [])
342
+ if not labels or not scores or len(labels) != len(scores):
343
+ logger.debug("HF router NLI: unexpected response shape: %s", str(data)[:120])
344
+ return None
345
+
346
+ # Map back to internal stance labels
347
+ label_to_stance = {
348
+ "supports the claim": "supporting",
349
+ "contradicts the claim": "contradicting",
350
+ "unrelated to the claim": "neutral",
351
+ }
352
+
353
+ # Pick the highest-scoring label
354
+ best_idx = scores.index(max(scores))
355
+ best_label = labels[best_idx].lower()
356
+ best_score = float(scores[best_idx])
357
+
358
+ stance = label_to_stance.get(best_label, "neutral")
359
+ logger.debug("HF router NLI: '%s' β†’ %s (%.2f)", best_label, stance, best_score)
360
+ return stance, best_score
361
+
362
+
363
+ def _parse_hf_nli_response(data) -> Optional[Tuple[str, float]]:
364
+ """
365
+ Legacy parser kept for reference β€” not called by current code.
366
+
367
+ Two possible shapes from old api-inference.huggingface.co:
368
+ A) List of {"label": "ENTAILMENT", "score": 0.97} dicts
369
+ B) Nested list from zero-shot endpoint
370
+ """
371
+ if not data:
372
+ return None
373
+
374
+ # Shape A: direct label list
375
+ if isinstance(data, list) and isinstance(data[0], dict) and "label" in data[0]:
376
+ best = max(data, key=lambda x: x.get("score", 0))
377
+ label = best["label"].lower()
378
+ score = float(best.get("score", 0.5))
379
+ stance = _NLI_LABEL_MAP.get(label, "neutral")
380
+ logger.debug("HF NLI API: %s β†’ %s (%.2f)", label, stance, score)
381
+ return stance, score
382
+
383
+ # Shape B: nested list [[{label, score}, ...]]
384
+ if isinstance(data, list) and isinstance(data[0], list):
385
+ flat = data[0]
386
+ best = max(flat, key=lambda x: x.get("score", 0))
387
+ label = best["label"].lower()
388
+ score = float(best.get("score", 0.5))
389
+ stance = _NLI_LABEL_MAP.get(label, "neutral")
390
+ logger.debug("HF NLI API (nested): %s β†’ %s (%.2f)", label, stance, score)
391
+ return stance, score
392
+
393
+ logger.debug("HF NLI: unexpected response shape: %s", str(data)[:120])
394
+ return None
395
+
396
+
397
+ # ── Local transformers pipeline ───────────────────────────────────────────────
398
+
399
+ _LOCAL_NLI_PIPE = None # cached pipeline instance
400
+ _LOCAL_NLI_TRIED = False # avoid retrying failed imports
401
+
402
+
403
+ def _local_nli(claim: str, premise: str) -> Optional[Tuple[str, float]]:
404
+ """
405
+ Local BART-large-MNLI via the `transformers` library.
406
+ Returns (stance, confidence) or None.
407
+ First call downloads ~1.6 GB model; subsequent calls are fast.
408
+ """
409
+ global _LOCAL_NLI_PIPE, _LOCAL_NLI_TRIED
410
+ if _LOCAL_NLI_TRIED and _LOCAL_NLI_PIPE is None:
411
+ return None # already failed once β€” skip
412
+
413
+ if _LOCAL_NLI_PIPE is None:
414
+ _LOCAL_NLI_TRIED = True
415
+ try:
416
+ from transformers import pipeline as hf_pipeline
417
+ logger.info("Loading facebook/bart-large-mnli locally (first call downloads ~1.6 GB)…")
418
+ _LOCAL_NLI_PIPE = hf_pipeline(
419
+ "zero-shot-classification",
420
+ model="facebook/bart-large-mnli",
421
+ device=-1, # CPU; set device=0 for GPU
422
+ )
423
+ logger.info("BART-MNLI local pipeline ready.")
424
+ except Exception as exc:
425
+ logger.warning(
426
+ "transformers import or model load failed: %s. "
427
+ "Install with: pip install transformers torch", exc
428
+ )
429
+ _LOCAL_NLI_PIPE = None
430
+ return None
431
+
432
+ try:
433
+ # zero-shot-classification with a three-way NLI hypothesis template
434
+ # We use claim as the candidate_label so BART tests:
435
+ # premise ENTAILS claim / CONTRADICTS claim / NEUTRAL
436
+ out = _LOCAL_NLI_PIPE(
437
+ premise,
438
+ candidate_labels=["true", "false", "unrelated"],
439
+ hypothesis_template=f"This text {{}} the following: {claim[:200]}",
440
+ )
441
+ # out = {"labels": [...], "scores": [...]}
442
+ label_map = {"true": "supporting", "false": "contradicting", "unrelated": "neutral"}
443
+ top_label = out["labels"][0].lower()
444
+ top_score = float(out["scores"][0])
445
+ stance = label_map.get(top_label, "neutral")
446
+ logger.debug("Local BART NLI: '%s' β†’ %s (%.2f)", premise[:40], stance, top_score)
447
+ return stance, top_score
448
+
449
+ except Exception as exc:
450
+ logger.warning("Local BART NLI inference failed: %s", exc)
451
+ return None
452
+
453
+
454
+ # ── In-process NLI result cache ───────────────────────────────────────────────
455
+
456
+ _NLI_CACHE: Dict[str, Tuple[str, float]] = {}
457
+
458
+
459
+ class NLIStanceLayer:
460
+ """
461
+ Layer 3: facebook/bart-large-mnli NLI stance classifier.
462
+
463
+ Completely free β€” no API key required for public HF models.
464
+ Optionally set HF_TOKEN in .env for higher Inference API rate limits.
465
+
466
+ Usage is identical to the old LLMStanceLayer interface so the
467
+ orchestrator (StanceClassifier) needs no changes.
468
+ """
469
+
470
+ def __init__(self) -> None:
471
+ self._enabled = _NLI_ENABLED
472
+ if self._enabled:
473
+ logger.info(
474
+ "NLI stance layer enabled (facebook/bart-large-mnli). "
475
+ "Primary: HF Inference API. Fallback: local transformers."
476
+ )
477
+ else:
478
+ logger.info("NLI stance layer disabled via STANCE_NLI_ENABLED=false")
479
+
480
+ def available(self) -> bool:
481
+ return self._enabled
482
+
483
+ def classify(self, claim: str, title: str, text: str) -> Optional[str]:
484
+ """
485
+ Returns 'supporting' | 'contradicting' | 'neutral' | None.
486
+ None = both API and local inference failed; caller uses keyword result.
487
+ """
488
+ if not self._enabled:
489
+ return None
490
+
491
+ cache_key = hashlib.md5(
492
+ f"{claim[:120]}||{title[:80]}||{text[:300]}".encode()
493
+ ).hexdigest()
494
+ if cache_key in _NLI_CACHE:
495
+ cached_stance, _ = _NLI_CACHE[cache_key]
496
+ return cached_stance
497
+
498
+ result = _bart_nli(claim=claim, title=title, text=text)
499
+ if result is None:
500
+ return None
501
+
502
+ stance, confidence = result
503
+ _NLI_CACHE[cache_key] = (stance, confidence)
504
+ logger.debug("NLI layer: '%s…' β†’ %s (%.2f)", claim[:40], stance, confidence)
505
+ return stance
506
+
507
+
508
+ # ─────────────────────────────────────────────────────────────────────────────
509
+ # Orchestrator β€” combines all three layers
510
+ # ─────────────────────────────────────────────────────────────────────────────
511
+
512
+ class StanceClassifier:
513
+ """
514
+ Three-layer stance classifier for source ↔ claim pairs.
515
+
516
+ Priority cascade:
517
+ 1. Semantic heuristics (pattern matching, no cost)
518
+ 2. Keyword layer (expanded lists, no cost)
519
+ 3. Claude LLM (only when uncertain + high-reliability source)
520
+
521
+ Usage (drop-in replacement for the old StanceClassifier)
522
+ ---------------------------------------------------------
523
+ classifier = StanceClassifier()
524
+ stance = classifier.classify(text=source_text, claim=claim_text)
525
+ # returns: "supporting" | "contradicting" | "neutral"
526
+
527
+ Advanced (with confidence + layer attribution)
528
+ ----------------------------------------------
529
+ stance, confidence, layer = classifier.classify_detailed(
530
+ title=source_title, text=source_text, claim=claim_text,
531
+ source_reliability=0.85,
532
+ )
533
+ """
534
+
535
+ # Minimum reliability a source needs to trigger LLM call when uncertain
536
+ _LLM_MIN_RELIABILITY = float(os.getenv("STANCE_LLM_MIN_REL", "0.40"))
537
+
538
+ def __init__(self) -> None:
539
+ self._heuristic = SemanticHeuristicLayer()
540
+ self._keywords = KeywordLayer()
541
+ self._llm = NLIStanceLayer()
542
+
543
+ # ------------------------------------------------------------------
544
+ # Drop-in API (matches old StanceClassifier.classify signature)
545
+ # ------------------------------------------------------------------
546
+
547
+ def classify(self, text: str, claim: str) -> str:
548
+ """
549
+ Backward-compatible interface: returns stance string only.
550
+ title defaults to first 80 chars of text.
551
+ """
552
+ title = text[:80]
553
+ stance, _, _ = self.classify_detailed(
554
+ title=title, text=text, claim=claim, source_reliability=0.50
555
+ )
556
+ return stance
557
+
558
+ # ------------------------------------------------------------------
559
+ # Full API
560
+ # ------------------------------------------------------------------
561
+
562
+ def classify_detailed(
563
+ self,
564
+ title: str,
565
+ text: str,
566
+ claim: str,
567
+ source_reliability: float = 0.50,
568
+ ) -> Tuple[str, float, str]:
569
+ """
570
+ Returns (stance, confidence, winning_layer).
571
+
572
+ stance = "supporting" | "contradicting" | "neutral"
573
+ confidence = 0.0–1.0
574
+ winning_layer = "heuristic" | "keyword" | "llm" | "default"
575
+ """
576
+ combined_text = (title + " " + text).strip()
577
+
578
+ # ── Layer 1: Semantic heuristics ─────────────────────────────────
579
+ h_stance = self._heuristic.classify(claim, combined_text)
580
+ if h_stance is not None:
581
+ return h_stance, 0.75, "heuristic"
582
+
583
+ # ── Layer 2: Keywords ─────────────────────────────────────────────
584
+ kw_stance, kw_conf = self._keywords.classify(combined_text)
585
+
586
+ # If keyword layer is confident, use it
587
+ if kw_conf >= 0.55:
588
+ return kw_stance, kw_conf, "keyword"
589
+
590
+ # ── Layer 3: NLI (BART-MNLI) β€” only for uncertain + credible sources ─
591
+ if (
592
+ self._llm.available()
593
+ and source_reliability >= self._LLM_MIN_RELIABILITY
594
+ ):
595
+ llm_stance = self._llm.classify(claim, title, text)
596
+ if llm_stance is not None:
597
+ return llm_stance, 0.90, "nli"
598
+
599
+ # ── Fallback: use whatever keyword layer found ──────────��─────────
600
+ return kw_stance, kw_conf, "keyword"
601
+
602
+
603
+ # ─────────────────────────────────────────────────────────────────────────────
604
+ # Backwards-compat shim: old code did StanceClassifier(scorer)
605
+ # New code supports both StanceClassifier() and StanceClassifier(scorer)
606
+ # ─────────────────────────────────────────────────────────────────────────────
607
+
608
+ def build_stance_classifier(scorer=None) -> StanceClassifier:
609
+ """Factory that accepts an optional DomainReliabilityScorer (ignored β€” kept for compat)."""
610
+ return StanceClassifier()
tasks/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # tasks/__init__.py
2
+ from tasks.graders import EasyGrader, MediumGrader, HardGrader, GraderFactory
3
+ from tasks.registry import TASKS, Task
4
+
5
+ __all__ = ["EasyGrader", "MediumGrader", "HardGrader", "GraderFactory", "TASKS", "Task"]
tasks/easy/scenarios.json ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "easy_001",
4
+ "difficulty": "easy",
5
+ "topic": "health_misinformation",
6
+ "tags": ["covid", "bleach", "health"],
7
+ "content": "BREAKING: Medical insiders confirm that drinking diluted bleach neutralises COVID-19 within minutes. Share to save lives!",
8
+ "claims": [
9
+ {
10
+ "id": "claim_001",
11
+ "text": "Drinking bleach cures or neutralises COVID-19",
12
+ "ground_truth_status": "false",
13
+ "ground_truth_confidence": 0.99
14
+ }
15
+ ],
16
+ "sources": [
17
+ {
18
+ "id": "src_001",
19
+ "title": "WHO: Do not consume bleach or disinfectants β€” they are fatal",
20
+ "text": "The World Health Organization warns that ingesting bleach or disinfectants is extremely dangerous and can be fatal. There is absolutely no evidence that bleach consumption treats or prevents COVID-19.",
21
+ "url": "https://www.who.int/emergencies/diseases/novel-coronavirus-2019/advice-for-public/myth-busters",
22
+ "stance": "contradicting",
23
+ "reliability": 0.99,
24
+ "source_type": "official",
25
+ "verification_weight": 1.0
26
+ },
27
+ {
28
+ "id": "src_002",
29
+ "title": "FDA Safety Alert: Toxic bleach consumption claims circulating online",
30
+ "text": "The FDA issues a safety alert warning consumers about dangerous online claims promoting bleach ingestion as a COVID-19 cure. Bleach contains sodium hypochlorite which causes severe chemical burns and organ failure when ingested.",
31
+ "url": "https://www.fda.gov/consumers/consumer-updates/why-you-should-not-use-ivermectin-prevent-or-treat-covid-19",
32
+ "stance": "contradicting",
33
+ "reliability": 0.97,
34
+ "source_type": "official",
35
+ "verification_weight": 1.0
36
+ },
37
+ {
38
+ "id": "src_003",
39
+ "title": "CDC: Household cleaning products are not COVID treatments",
40
+ "text": "The Centers for Disease Control and Prevention explicitly states that household disinfectants including bleach are not treatments for COVID-19. Consuming these products causes severe poisoning.",
41
+ "url": "https://www.cdc.gov/coronavirus/2019-ncov/prevent-getting-sick/prevention.html",
42
+ "stance": "contradicting",
43
+ "reliability": 0.97,
44
+ "source_type": "official",
45
+ "verification_weight": 1.0
46
+ },
47
+ {
48
+ "id": "src_004",
49
+ "title": "Anonymous health blog: Natural detox claims",
50
+ "text": "Some alternative practitioners suggest diluted solutions may help cleanse the body, though mainstream medicine disagrees strongly.",
51
+ "url": "https://example-health-blog.invalid/detox-claims",
52
+ "stance": "supporting",
53
+ "reliability": 0.05,
54
+ "source_type": "blog",
55
+ "verification_weight": 0.1
56
+ },
57
+ {
58
+ "id": "src_005",
59
+ "title": "Poison Control Center: Bleach ingestion emergencies spiked during pandemic",
60
+ "text": "National Poison Control Centers reported a significant increase in bleach ingestion calls during 2020, directly correlating with social media misinformation about bleach as a COVID cure.",
61
+ "url": "https://www.poison.org/articles/covid-19-and-cleaning-products",
62
+ "stance": "contradicting",
63
+ "reliability": 0.95,
64
+ "source_type": "official",
65
+ "verification_weight": 0.95
66
+ }
67
+ ],
68
+ "ground_truth": "remove",
69
+ "explanation_hint": "Dangerous health misinformation with strong contradicting evidence from multiple authoritative sources. Should be removed."
70
+ },
71
+ {
72
+ "id": "easy_002",
73
+ "difficulty": "easy",
74
+ "topic": "science_denial",
75
+ "tags": ["climate", "nasa", "science"],
76
+ "content": "NASA scientists ADMIT climate change data has been fabricated for 30 years. The whole global warming scam is exposed!",
77
+ "claims": [
78
+ {
79
+ "id": "claim_001",
80
+ "text": "NASA scientists admitted climate change data is fabricated",
81
+ "ground_truth_status": "false",
82
+ "ground_truth_confidence": 0.99
83
+ },
84
+ {
85
+ "id": "claim_002",
86
+ "text": "Global warming is a scam",
87
+ "ground_truth_status": "false",
88
+ "ground_truth_confidence": 0.97
89
+ }
90
+ ],
91
+ "sources": [
92
+ {
93
+ "id": "src_001",
94
+ "title": "NASA: 97% of Climate Scientists Agree on Human-Caused Warming",
95
+ "text": "NASA's official position, supported by multiple independent data sets, confirms that global average temperatures have risen by approximately 1.1Β°C since the late 19th century, driven by human activities. No NASA scientist has made such admissions.",
96
+ "url": "https://climate.nasa.gov/scientific-consensus/",
97
+ "stance": "contradicting",
98
+ "reliability": 0.99,
99
+ "source_type": "official",
100
+ "verification_weight": 1.0
101
+ },
102
+ {
103
+ "id": "src_002",
104
+ "title": "NOAA Global Surface Temperature Data β€” Independent Verification",
105
+ "text": "NOAA's independently verified global temperature records, cross-checked against satellite data, show consistent warming trends consistent with NASA measurements. Data is openly available for third-party verification.",
106
+ "url": "https://www.noaa.gov/climate",
107
+ "stance": "contradicting",
108
+ "reliability": 0.98,
109
+ "source_type": "official",
110
+ "verification_weight": 1.0
111
+ },
112
+ {
113
+ "id": "src_003",
114
+ "title": "Climate conspiracy blog post",
115
+ "text": "An unnamed source claims a NASA whistleblower revealed data manipulation. No names, documents or evidence are provided.",
116
+ "url": "https://climateskeptic-blog.invalid/nasa-expose",
117
+ "stance": "supporting",
118
+ "reliability": 0.04,
119
+ "source_type": "blog",
120
+ "verification_weight": 0.05
121
+ },
122
+ {
123
+ "id": "src_004",
124
+ "title": "Reuters Fact Check: No NASA admission of fabricated climate data",
125
+ "text": "Reuters fact-checkers found no credible evidence of any NASA admission about fabricated climate data. The claim originates from known misinformation networks.",
126
+ "url": "https://www.reuters.com/fact-check",
127
+ "stance": "contradicting",
128
+ "reliability": 0.92,
129
+ "source_type": "news",
130
+ "verification_weight": 0.95
131
+ },
132
+ {
133
+ "id": "src_005",
134
+ "title": "IPCC Sixth Assessment Report: Unequivocal Evidence of Climate Change",
135
+ "text": "The Intergovernmental Panel on Climate Change, drawing on thousands of peer-reviewed studies, concluded it is unequivocal that human influence has warmed the atmosphere, ocean and land.",
136
+ "url": "https://www.ipcc.ch/assessment-report/ar6/",
137
+ "stance": "contradicting",
138
+ "reliability": 0.99,
139
+ "source_type": "official",
140
+ "verification_weight": 1.0
141
+ }
142
+ ],
143
+ "ground_truth": "remove",
144
+ "explanation_hint": "Both claims are clearly false, contradicted by multiple top-tier scientific authorities. Content should be removed."
145
+ },
146
+ {
147
+ "id": "easy_003",
148
+ "difficulty": "easy",
149
+ "topic": "vaccine_safety",
150
+ "tags": ["vaccine", "mrna", "dna"],
151
+ "content": "WARNING: mRNA vaccines permanently alter your DNA. Scientists confirm irreversible genetic changes in vaccinated individuals.",
152
+ "claims": [
153
+ {
154
+ "id": "claim_001",
155
+ "text": "mRNA vaccines permanently alter human DNA",
156
+ "ground_truth_status": "false",
157
+ "ground_truth_confidence": 0.99
158
+ }
159
+ ],
160
+ "sources": [
161
+ {
162
+ "id": "src_001",
163
+ "title": "CDC: mRNA vaccines do not alter DNA β€” here is how they work",
164
+ "text": "mRNA vaccines work by giving cells instructions to build the spike protein. The mRNA never enters the cell nucleus where DNA is stored and is degraded within a few days. It cannot alter DNA.",
165
+ "url": "https://www.cdc.gov/coronavirus/2019-ncov/vaccines/different-vaccines/mrna.html",
166
+ "stance": "contradicting",
167
+ "reliability": 0.97,
168
+ "source_type": "official",
169
+ "verification_weight": 1.0
170
+ },
171
+ {
172
+ "id": "src_002",
173
+ "title": "Johns Hopkins Medicine: The science behind mRNA vaccines",
174
+ "text": "mRNA is messenger RNA, a short-lived molecule that cannot be converted back to DNA under normal biological conditions. Human cells lack the reverse transcriptase enzyme required to integrate mRNA into the genome.",
175
+ "url": "https://www.hopkinsmedicine.org/health/conditions-and-diseases/coronavirus/is-the-covid19-vaccine-safe",
176
+ "stance": "contradicting",
177
+ "reliability": 0.96,
178
+ "source_type": "official",
179
+ "verification_weight": 1.0
180
+ },
181
+ {
182
+ "id": "src_003",
183
+ "title": "Anti-vaccine forum post claiming DNA alteration",
184
+ "text": "Forum user 'TruthSeeker' claims to have seen documents proving DNA alteration. No scientific references provided.",
185
+ "url": "https://antivax-forum.invalid/dna-alteration",
186
+ "stance": "supporting",
187
+ "reliability": 0.03,
188
+ "source_type": "social",
189
+ "verification_weight": 0.05
190
+ },
191
+ {
192
+ "id": "src_004",
193
+ "title": "Nature: mRNA vaccine mechanism β€” peer-reviewed overview",
194
+ "text": "Peer-reviewed analysis in Nature confirms mRNA vaccines deliver transient genetic instructions that degrade rapidly. Integration into host DNA would require enzymatic machinery absent in normal human somatic cells.",
195
+ "url": "https://www.nature.com/articles/s41586-021-03290-1",
196
+ "stance": "contradicting",
197
+ "reliability": 0.98,
198
+ "source_type": "academic",
199
+ "verification_weight": 1.0
200
+ },
201
+ {
202
+ "id": "src_005",
203
+ "title": "WHO Vaccine Safety FAQ",
204
+ "text": "WHO confirms mRNA vaccines do not interact with or alter an individual's genetic makeup. The mRNA from the vaccine is broken down quickly by the body.",
205
+ "url": "https://www.who.int/news-room/questions-and-answers/item/coronavirus-disease-(covid-19)-vaccines",
206
+ "stance": "contradicting",
207
+ "reliability": 0.99,
208
+ "source_type": "official",
209
+ "verification_weight": 1.0
210
+ }
211
+ ],
212
+ "ground_truth": "remove",
213
+ "explanation_hint": "Clear health misinformation contradicted by CDC, WHO, Johns Hopkins, and peer-reviewed science."
214
+ },
215
+ {
216
+ "id": "easy_004",
217
+ "difficulty": "easy",
218
+ "topic": "factual_accuracy",
219
+ "tags": ["geography", "capitals"],
220
+ "content": "Fun fact: Australia's capital city is Sydney β€” the largest and most famous city in the country!",
221
+ "claims": [
222
+ {
223
+ "id": "claim_001",
224
+ "text": "Sydney is the capital city of Australia",
225
+ "ground_truth_status": "false",
226
+ "ground_truth_confidence": 1.0
227
+ }
228
+ ],
229
+ "sources": [
230
+ {
231
+ "id": "src_001",
232
+ "title": "Australian Government: Canberra is Australia's capital",
233
+ "text": "The Australian capital is Canberra, located in the Australian Capital Territory. It became the capital in 1913 as a compromise between Sydney and Melbourne. Sydney is the largest city but not the capital.",
234
+ "url": "https://www.australia.gov.au/about-australia/our-country/our-capital-city",
235
+ "stance": "contradicting",
236
+ "reliability": 1.0,
237
+ "source_type": "official",
238
+ "verification_weight": 1.0
239
+ },
240
+ {
241
+ "id": "src_002",
242
+ "title": "Encyclopedia Britannica: Canberra β€” capital of Australia",
243
+ "text": "Canberra, city and capital of Australia. Canberra is located at the northern end of the Australian Capital Territory and is Australia's only inland city among the major cities.",
244
+ "url": "https://www.britannica.com/place/Canberra",
245
+ "stance": "contradicting",
246
+ "reliability": 0.95,
247
+ "source_type": "academic",
248
+ "verification_weight": 0.95
249
+ },
250
+ {
251
+ "id": "src_003",
252
+ "title": "Travel blog: Sydney β€” Australia's iconic city",
253
+ "text": "Many tourists assume Sydney is the capital because of its size and fame, but the actual capital is Canberra.",
254
+ "url": "https://travel-australia.example/sydney",
255
+ "stance": "contradicting",
256
+ "reliability": 0.70,
257
+ "source_type": "blog",
258
+ "verification_weight": 0.7
259
+ }
260
+ ],
261
+ "ground_truth": "flag",
262
+ "explanation_hint": "Factually incorrect but not dangerous misinformation. Flag rather than remove β€” it's a common geographic misconception, not a harmful claim."
263
+ },
264
+ {
265
+ "id": "easy_005",
266
+ "difficulty": "easy",
267
+ "topic": "legitimate_news",
268
+ "tags": ["science", "space", "nasa"],
269
+ "content": "NASA's James Webb Space Telescope has captured images of galaxies from over 13 billion years ago, providing unprecedented views of the early universe.",
270
+ "claims": [
271
+ {
272
+ "id": "claim_001",
273
+ "text": "James Webb Space Telescope has imaged galaxies from over 13 billion years ago",
274
+ "ground_truth_status": "true",
275
+ "ground_truth_confidence": 0.95
276
+ }
277
+ ],
278
+ "sources": [
279
+ {
280
+ "id": "src_001",
281
+ "title": "NASA Webb Telescope: First Deep Field images released",
282
+ "text": "NASA's James Webb Space Telescope has produced the deepest and sharpest infrared image of the distant universe to date. The image shows the galaxy cluster SMACS 0723 and includes some of the faintest objects ever observed, some dating back over 13 billion years.",
283
+ "url": "https://www.nasa.gov/image-feature/goddard/2022/nasa-s-webb-delivers-deepest-infrared-image-of-universe-yet",
284
+ "stance": "supporting",
285
+ "reliability": 0.99,
286
+ "source_type": "official",
287
+ "verification_weight": 1.0
288
+ },
289
+ {
290
+ "id": "src_002",
291
+ "title": "Nature Astronomy: Webb observations confirm early universe galaxies",
292
+ "text": "Peer-reviewed analysis confirms JWST has detected light from galaxies with redshifts exceeding z=13, corresponding to approximately 320 million years after the Big Bang β€” over 13.4 billion years ago.",
293
+ "url": "https://www.nature.com/articles/s41550-023-02046-1",
294
+ "stance": "supporting",
295
+ "reliability": 0.98,
296
+ "source_type": "academic",
297
+ "verification_weight": 1.0
298
+ },
299
+ {
300
+ "id": "src_003",
301
+ "title": "BBC Science: What Webb has shown us about the early universe",
302
+ "text": "The James Webb Space Telescope has revolutionised our understanding of the early universe, detecting galaxies from a time when the universe was less than 500 million years old.",
303
+ "url": "https://www.bbc.co.uk/news/science-environment-62225696",
304
+ "stance": "supporting",
305
+ "reliability": 0.88,
306
+ "source_type": "news",
307
+ "verification_weight": 0.9
308
+ }
309
+ ],
310
+ "ground_truth": "allow",
311
+ "explanation_hint": "Factually accurate scientific news supported by NASA and peer-reviewed sources. Allow."
312
+ }
313
+ ]
tasks/graders.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tasks/graders.py
3
+ ================
4
+ Deterministic graders for easy / medium / hard task difficulties.
5
+
6
+ Each grader scores a completed episode on a 0.0–1.0 scale using four
7
+ independent sub-scores:
8
+
9
+ 1. decision_score (0–0.40) β€” correctness of the final moderation call
10
+ 2. evidence_score (0–0.30) β€” how well the agent used available sources
11
+ 3. reasoning_score (0–0.20) β€” depth of claim verification
12
+ 4. efficiency_score (0–0.10) β€” did the agent avoid unnecessary steps?
13
+
14
+ Weights differ per difficulty to reflect what matters most:
15
+ Easy β†’ decision is paramount; evidence and reasoning are straightforward
16
+ Medium β†’ evidence quality and reasoning depth matter more
17
+ Hard β†’ all four dimensions matter roughly equally; efficiency penalised less
18
+
19
+ Usage
20
+ -----
21
+ from tasks.graders import GraderFactory
22
+
23
+ grader = GraderFactory.get("medium")
24
+ score = grader.score(
25
+ decision="flag",
26
+ ground_truth="flag",
27
+ sources_checked=["src_001", "src_002", "src_003"],
28
+ claims_verified=["claim_001", "claim_002"],
29
+ step_count=8,
30
+ all_source_ids=["src_001", ..., "src_006"],
31
+ all_claim_ids=["claim_001", "claim_002"],
32
+ )
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from abc import ABC, abstractmethod
38
+ from typing import List
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Partial-credit adjacency for decisions
43
+ # ---------------------------------------------------------------------------
44
+ # (predicted, ground_truth) β†’ credit (0–1)
45
+ _DECISION_CREDIT = {
46
+ # Exact match
47
+ ("allow", "allow"): 1.0,
48
+ ("flag", "flag"): 1.0,
49
+ ("remove", "remove"): 1.0,
50
+ # One step away
51
+ ("flag", "remove"): 0.5,
52
+ ("remove", "flag"): 0.5,
53
+ ("flag", "allow"): 0.5,
54
+ ("allow", "flag"): 0.5,
55
+ # Far apart
56
+ ("allow", "remove"): 0.0,
57
+ ("remove", "allow"): 0.0,
58
+ ("none", "allow"): 0.0,
59
+ ("none", "flag"): 0.0,
60
+ ("none", "remove"): 0.0,
61
+ }
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Base grader
66
+ # ---------------------------------------------------------------------------
67
+
68
+ class BaseGrader(ABC):
69
+
70
+ # Subclasses set these weights (must sum to 1.0)
71
+ W_DECISION : float = 0.40
72
+ W_EVIDENCE : float = 0.30
73
+ W_REASONING : float = 0.20
74
+ W_EFFICIENCY: float = 0.10
75
+
76
+ # Subclasses set these thresholds
77
+ IDEAL_MAX_STEPS : int = 10
78
+ MIN_SOURCE_FRAC : float = 0.40 # fraction of all sources that should be checked
79
+ MIN_CLAIM_FRAC : float = 0.50 # fraction of all claims that should be verified
80
+
81
+ def grade(self, episode: dict) -> float:
82
+ """
83
+ OpenEnv-standard grader interface.
84
+ Accepts a completed episode dict and returns a score in (0, 1).
85
+ """
86
+ return self.score(
87
+ decision=episode.get("final_decision") or episode.get("decision") or "none",
88
+ ground_truth=episode.get("ground_truth") or episode.get("correct_decision") or "none",
89
+ sources_checked=episode.get("sources_checked") or [],
90
+ claims_verified=episode.get("claims_verified") or [],
91
+ step_count=int(episode.get("step_count") or 0),
92
+ all_source_ids=episode.get("all_source_ids") or [],
93
+ all_claim_ids=episode.get("all_claim_ids") or [],
94
+ )
95
+
96
+ def score(
97
+ self,
98
+ decision: str,
99
+ ground_truth: str,
100
+ sources_checked: List[str],
101
+ claims_verified: List[str],
102
+ step_count: int,
103
+ all_source_ids: List[str],
104
+ all_claim_ids: List[str],
105
+ ) -> float:
106
+ """Return a normalised score strictly in (0, 1)."""
107
+ d = self._decision_score(decision, ground_truth)
108
+ e = self._evidence_score(sources_checked, all_source_ids)
109
+ r = self._reasoning_score(claims_verified, all_claim_ids)
110
+ f = self._efficiency_score(step_count)
111
+
112
+ raw = (
113
+ self.W_DECISION * d
114
+ + self.W_EVIDENCE * e
115
+ + self.W_REASONING * r
116
+ + self.W_EFFICIENCY * f
117
+ )
118
+ # Clamp strictly inside (0, 1) β€” validator requires scores never equal 0.0 or 1.0
119
+ return round(max(0.005, min(0.995, raw)), 4)
120
+
121
+ def score_breakdown(
122
+ self,
123
+ decision: str,
124
+ ground_truth: str,
125
+ sources_checked: List[str],
126
+ claims_verified: List[str],
127
+ step_count: int,
128
+ all_source_ids: List[str],
129
+ all_claim_ids: List[str],
130
+ ) -> dict:
131
+ """Return full breakdown for audit / dashboards."""
132
+ d = self._decision_score(decision, ground_truth)
133
+ e = self._evidence_score(sources_checked, all_source_ids)
134
+ r = self._reasoning_score(claims_verified, all_claim_ids)
135
+ f = self._efficiency_score(step_count)
136
+ total = self.W_DECISION*d + self.W_EVIDENCE*e + self.W_REASONING*r + self.W_EFFICIENCY*f
137
+ return {
138
+ "decision_sub_score": round(d, 4),
139
+ "evidence_sub_score": round(e, 4),
140
+ "reasoning_sub_score": round(r, 4),
141
+ "efficiency_sub_score": round(f, 4),
142
+ "weights": {
143
+ "decision": self.W_DECISION,
144
+ "evidence": self.W_EVIDENCE,
145
+ "reasoning": self.W_REASONING,
146
+ "efficiency": self.W_EFFICIENCY,
147
+ },
148
+ "total": round(max(0.005, min(0.995, total)), 4),
149
+ }
150
+
151
+ # ------------------------------------------------------------------
152
+ # Sub-score methods (shared; can be overridden per difficulty)
153
+ # ------------------------------------------------------------------
154
+
155
+ def _decision_score(self, decision: str, ground_truth: str) -> float:
156
+ key = (decision.lower(), ground_truth.lower())
157
+ return _DECISION_CREDIT.get(key, 0.0)
158
+
159
+ def _evidence_score(
160
+ self, sources_checked: List[str], all_source_ids: List[str]
161
+ ) -> float:
162
+ if not all_source_ids:
163
+ return 1.0
164
+ frac = len(set(sources_checked)) / len(all_source_ids)
165
+ # Reward checking at least MIN_SOURCE_FRAC of available sources
166
+ if frac >= 1.0:
167
+ return 1.0
168
+ if frac >= self.MIN_SOURCE_FRAC:
169
+ # Scale linearly from MIN_SOURCE_FRAC→1 mapping to 0.5→1.0
170
+ slope = 0.5 / (1.0 - self.MIN_SOURCE_FRAC)
171
+ return 0.5 + slope * (frac - self.MIN_SOURCE_FRAC)
172
+ # Below minimum β€” partial credit only
173
+ return frac / self.MIN_SOURCE_FRAC * 0.5
174
+
175
+ def _reasoning_score(
176
+ self, claims_verified: List[str], all_claim_ids: List[str]
177
+ ) -> float:
178
+ if not all_claim_ids:
179
+ return 1.0
180
+ frac = len(set(claims_verified)) / len(all_claim_ids)
181
+ if frac >= self.MIN_CLAIM_FRAC:
182
+ return min(1.0, 0.6 + 0.4 * (frac - self.MIN_CLAIM_FRAC) / (1.0 - self.MIN_CLAIM_FRAC + 1e-9))
183
+ return frac / self.MIN_CLAIM_FRAC * 0.6
184
+
185
+ def _efficiency_score(self, step_count: int) -> float:
186
+ if step_count <= 0:
187
+ return 0.0
188
+ if step_count <= self.IDEAL_MAX_STEPS:
189
+ return 1.0
190
+ # Linear decay past ideal; floor at 0
191
+ overage = step_count - self.IDEAL_MAX_STEPS
192
+ return max(0.0, 1.0 - 0.1 * overage)
193
+
194
+ @property
195
+ @abstractmethod
196
+ def difficulty(self) -> str: ...
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Difficulty-specific graders
201
+ # ---------------------------------------------------------------------------
202
+
203
+ class EasyGrader(BaseGrader):
204
+ """
205
+ Easy scenarios have clear-cut answers.
206
+ Decision correctness dominates; efficiency is important.
207
+ """
208
+ difficulty = "easy"
209
+ W_DECISION = 0.50
210
+ W_EVIDENCE = 0.25
211
+ W_REASONING = 0.15
212
+ W_EFFICIENCY = 0.10
213
+
214
+ IDEAL_MAX_STEPS = 8
215
+ MIN_SOURCE_FRAC = 0.33 # at least 1/3 of sources
216
+ MIN_CLAIM_FRAC = 0.50
217
+
218
+
219
+ class MediumGrader(BaseGrader):
220
+ """
221
+ Medium scenarios are partially true / misleading.
222
+ Evidence quality and reasoning depth matter more.
223
+ """
224
+ difficulty = "medium"
225
+ W_DECISION = 0.35
226
+ W_EVIDENCE = 0.35
227
+ W_REASONING = 0.20
228
+ W_EFFICIENCY = 0.10
229
+
230
+ IDEAL_MAX_STEPS = 12
231
+ MIN_SOURCE_FRAC = 0.50
232
+ MIN_CLAIM_FRAC = 0.75
233
+
234
+ def _evidence_score(
235
+ self, sources_checked: List[str], all_source_ids: List[str]
236
+ ) -> float:
237
+ """Medium: penalise lightly for checking < 50%, harshly for < 33%."""
238
+ if not all_source_ids:
239
+ return 1.0
240
+ frac = len(set(sources_checked)) / len(all_source_ids)
241
+ if frac >= 0.75:
242
+ return 1.0
243
+ if frac >= 0.50:
244
+ return 0.6 + 0.4 * (frac - 0.50) / 0.25
245
+ if frac >= 0.33:
246
+ return 0.3 + 0.3 * (frac - 0.33) / 0.17
247
+ return frac / 0.33 * 0.3
248
+
249
+
250
+ class HardGrader(BaseGrader):
251
+ """
252
+ Hard scenarios have conflicting evidence and require nuanced reasoning.
253
+ All four dimensions count equally; efficiency is barely penalised.
254
+ """
255
+ difficulty = "hard"
256
+ W_DECISION = 0.30
257
+ W_EVIDENCE = 0.30
258
+ W_REASONING = 0.30
259
+ W_EFFICIENCY = 0.10
260
+
261
+ IDEAL_MAX_STEPS = 18
262
+ MIN_SOURCE_FRAC = 0.60
263
+ MIN_CLAIM_FRAC = 1.00 # all claims must be verified for full credit
264
+
265
+ def _decision_score(self, decision: str, ground_truth: str) -> float:
266
+ """Hard: never give 0 for a flag/remove swap β€” these are genuinely ambiguous."""
267
+ base = super()._decision_score(decision, ground_truth)
268
+ # Give 0.25 minimum for any non-trivially wrong answer
269
+ if base == 0.0 and decision in ("flag", "remove"):
270
+ return 0.25
271
+ return base
272
+
273
+ def _efficiency_score(self, step_count: int) -> float:
274
+ """Hard: efficiency barely matters β€” thorough exploration is rewarded."""
275
+ if step_count <= self.IDEAL_MAX_STEPS:
276
+ return 1.0
277
+ overage = step_count - self.IDEAL_MAX_STEPS
278
+ return max(0.0, 1.0 - 0.05 * overage)
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Factory
283
+ # ---------------------------------------------------------------------------
284
+
285
+ class GraderFactory:
286
+ _REGISTRY = {
287
+ "easy": EasyGrader,
288
+ "medium": MediumGrader,
289
+ "hard": HardGrader,
290
+ }
291
+
292
+ @classmethod
293
+ def get(cls, difficulty: str) -> BaseGrader:
294
+ key = difficulty.lower().strip()
295
+ if key not in cls._REGISTRY:
296
+ raise ValueError(
297
+ f"Unknown difficulty '{difficulty}'. "
298
+ f"Valid options: {list(cls._REGISTRY.keys())}"
299
+ )
300
+ return cls._REGISTRY[key]()
301
+
302
+ @classmethod
303
+ def register(cls, difficulty: str, grader_cls: type) -> None:
304
+ """Allow enterprise teams to plug in custom graders."""
305
+ cls._REGISTRY[difficulty.lower()] = grader_cls
tasks/hard/scenarios.json ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "hard_001",
4
+ "difficulty": "hard",
5
+ "topic": "geopolitics",
6
+ "tags": ["ukraine", "nato", "war", "conflict"],
7
+ "content": "NATO provoked Russia into invading Ukraine by promising Ukraine membership, violating agreements made at the end of the Cold War. Russia's invasion was a defensive response to Western aggression.",
8
+ "claims": [
9
+ {
10
+ "id": "claim_001",
11
+ "text": "NATO promised Russia that Ukraine would never become a member",
12
+ "ground_truth_status": "partially_true",
13
+ "ground_truth_confidence": 0.60
14
+ },
15
+ {
16
+ "id": "claim_002",
17
+ "text": "Russia's invasion was a defensive response to NATO expansion",
18
+ "ground_truth_status": "false",
19
+ "ground_truth_confidence": 0.65
20
+ },
21
+ {
22
+ "id": "claim_003",
23
+ "text": "NATO violated formal written agreements with Russia",
24
+ "ground_truth_status": "false",
25
+ "ground_truth_confidence": 0.75
26
+ }
27
+ ],
28
+ "sources": [
29
+ {
30
+ "id": "src_001",
31
+ "title": "National Security Archive: What NATO leaders said about expansion in 1990",
32
+ "text": "Declassified documents show Western leaders gave verbal assurances in 1990 about NATO not expanding eastward, but these were never codified in a binding treaty. The NATO-Russia Founding Act of 1997 does not prohibit membership for former Soviet states.",
33
+ "url": "https://nsarchive.gwu.edu/briefing-book/russia-programs/2017-12-12/nato-expansion-what-gorbachev-heard",
34
+ "stance": "neutral",
35
+ "reliability": 0.92,
36
+ "source_type": "academic",
37
+ "verification_weight": 0.92
38
+ },
39
+ {
40
+ "id": "src_002",
41
+ "title": "Brookings Institution: Did the West break its promise to Russia?",
42
+ "text": "Analysis finds Western leaders did give informal assurances to Gorbachev in 1990, but these were oral statements not reflected in any signed treaty. NATO membership is an open-door policy codified in the Washington Treaty. No binding promise was broken.",
43
+ "url": "https://www.brookings.edu/blog/order-from-chaos/2022/02/28/did-the-west-break-its-promise-to-moscow-not-to-expand-nato/",
44
+ "stance": "contradicting",
45
+ "reliability": 0.88,
46
+ "source_type": "academic",
47
+ "verification_weight": 0.88
48
+ },
49
+ {
50
+ "id": "src_003",
51
+ "title": "Mearsheimer: Why the West is principally responsible for the Ukraine crisis",
52
+ "text": "Offensive realism scholar John Mearsheimer argues that NATO expansion posed an existential threat in Russia's strategic calculus, making conflict inevitable. He contends the West provoked the crisis through a policy of integrating Ukraine.",
53
+ "url": "https://www.foreignaffairs.com/ukraine/why-west-principally-responsible-ukraine-crisis",
54
+ "stance": "supporting",
55
+ "reliability": 0.80,
56
+ "source_type": "academic",
57
+ "verification_weight": 0.75
58
+ },
59
+ {
60
+ "id": "src_004",
61
+ "title": "UN General Assembly Resolution ES-11/1: Condemning Russia's aggression",
62
+ "text": "141 UN member states voted to condemn Russia's invasion of Ukraine as a violation of the UN Charter. The resolution demands Russia's immediate withdrawal and rejects the framing of the invasion as defensive.",
63
+ "url": "https://undocs.org/en/A/RES/ES-11/1",
64
+ "stance": "contradicting",
65
+ "reliability": 0.96,
66
+ "source_type": "official",
67
+ "verification_weight": 0.95
68
+ },
69
+ {
70
+ "id": "src_005",
71
+ "title": "Carnegie Endowment: Roots of the Ukraine war β€” multiple causes",
72
+ "text": "Carnegie analysis identifies multiple contributing factors to the conflict including NATO expansion, Crimea 2014, Donbas conflict, and Russian domestic politics. Framing the invasion as purely defensive ignores Russia's maximalist territorial ambitions.",
73
+ "url": "https://carnegieendowment.org/2022/06/13/roots-of-ukraine-war-keeping-country-together-pub-87171",
74
+ "stance": "contradicting",
75
+ "reliability": 0.87,
76
+ "source_type": "academic",
77
+ "verification_weight": 0.87
78
+ },
79
+ {
80
+ "id": "src_006",
81
+ "title": "RT (Russia Today): Russia's justified response to NATO aggression",
82
+ "text": "Russian state media presents the invasion as a special military operation to de-Nazify Ukraine and protect Russian speakers, and a necessary response to years of NATO encirclement.",
83
+ "url": "https://www.rt.com/russia/ukraine-nato-expansion/",
84
+ "stance": "supporting",
85
+ "reliability": 0.15,
86
+ "source_type": "news",
87
+ "verification_weight": 0.1
88
+ },
89
+ {
90
+ "id": "src_007",
91
+ "title": "Budapest Memorandum 1994: What was actually signed",
92
+ "text": "The Budapest Memorandum gave Ukraine security assurances (not guarantees) in exchange for giving up nuclear weapons. Russia, the US, and UK signed it. Russia's violation of Ukrainian sovereignty directly contravenes its own commitments in this document.",
93
+ "url": "https://www.un.org/en/sc/repertoire/96-99/Chapter%208/EUROPE/96-99_8-2-24-UKRAINE.pdf",
94
+ "stance": "contradicting",
95
+ "reliability": 0.97,
96
+ "source_type": "official",
97
+ "verification_weight": 1.0
98
+ }
99
+ ],
100
+ "ground_truth": "flag",
101
+ "explanation_hint": "Highly contested geopolitical narrative. Claim 1 has partial basis (informal assurances existed). Claims 2-3 are mostly false (no binding agreements broken, invasion not legally defensive). Complex enough to flag rather than remove β€” the debate has legitimate academic dimensions."
102
+ },
103
+ {
104
+ "id": "hard_002",
105
+ "difficulty": "hard",
106
+ "topic": "public_health_policy",
107
+ "tags": ["lockdown", "covid", "policy", "deaths"],
108
+ "content": "COVID lockdowns killed more people than COVID itself. The economic devastation, mental health crisis, and delayed medical treatments caused excess deaths exceeding the pandemic toll.",
109
+ "claims": [
110
+ {
111
+ "id": "claim_001",
112
+ "text": "Lockdowns caused excess deaths exceeding COVID's direct death toll",
113
+ "ground_truth_status": "false",
114
+ "ground_truth_confidence": 0.65
115
+ },
116
+ {
117
+ "id": "claim_002",
118
+ "text": "Lockdowns caused significant excess mortality from delayed treatments and mental health",
119
+ "ground_truth_status": "partially_true",
120
+ "ground_truth_confidence": 0.72
121
+ },
122
+ {
123
+ "id": "claim_003",
124
+ "text": "Lockdowns caused economic devastation with major mortality consequences",
125
+ "ground_truth_status": "partially_true",
126
+ "ground_truth_confidence": 0.60
127
+ }
128
+ ],
129
+ "sources": [
130
+ {
131
+ "id": "src_001",
132
+ "title": "The Lancet: Excess mortality during COVID-19 pandemic β€” global estimates",
133
+ "text": "Lancet analysis estimated 18.2 million excess deaths attributable directly or indirectly to COVID-19 in 2020-2021. The vast majority were directly caused by SARS-CoV-2, not lockdown policies.",
134
+ "url": "https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(21)02796-3/fulltext",
135
+ "stance": "contradicting",
136
+ "reliability": 0.97,
137
+ "source_type": "academic",
138
+ "verification_weight": 0.97
139
+ },
140
+ {
141
+ "id": "src_002",
142
+ "title": "Johns Hopkins Carey: A literature review and meta-analysis of the effects of lockdowns",
143
+ "text": "A meta-analysis found lockdowns had a modest direct effect on COVID mortality (reducing it by approximately 3.2%). The study argued costs may have outweighed benefits but did not claim lockdowns killed more than the disease.",
144
+ "url": "https://sites.krieger.jhu.edu/iae/files/2022/01/A-Literature-Review-and-Meta-Analysis-of-the-Effects-of-Lockdowns-on-COVID-19-Mortality.pdf",
145
+ "stance": "neutral",
146
+ "reliability": 0.84,
147
+ "source_type": "academic",
148
+ "verification_weight": 0.82
149
+ },
150
+ {
151
+ "id": "src_003",
152
+ "title": "BMJ: Cancer screening dropped 89% during first lockdown in UK",
153
+ "text": "BMJ analysis found cancer screening attendance collapsed during lockdowns, with modelling suggesting 3,500 additional cancer deaths in England expected due to diagnostic delays.",
154
+ "url": "https://www.bmj.com/content/372/bmj.n421",
155
+ "stance": "supporting",
156
+ "reliability": 0.94,
157
+ "source_type": "academic",
158
+ "verification_weight": 0.93
159
+ },
160
+ {
161
+ "id": "src_004",
162
+ "title": "WHO: Mental health crisis during pandemic",
163
+ "text": "WHO reported a 25% increase in anxiety and depression globally during the first year of COVID-19. Isolation, economic stress, and grief contributed, though attributing mortality to lockdowns specifically versus pandemic stress broadly is methodologically difficult.",
164
+ "url": "https://www.who.int/news/item/02-03-2022-covid-19-pandemic-triggers-25-increase-in-prevalence-of-anxiety-and-depression-worldwide",
165
+ "stance": "neutral",
166
+ "reliability": 0.97,
167
+ "source_type": "official",
168
+ "verification_weight": 0.95
169
+ },
170
+ {
171
+ "id": "src_005",
172
+ "title": "NBER: Economic costs of the pandemic β€” lockdowns vs disease",
173
+ "text": "National Bureau of Economic Research analysis found voluntary social distancing and fear of infection were larger drivers of economic contraction than government mandates. Disentangling lockdown effects from pandemic effects is methodologically challenging.",
174
+ "url": "https://www.nber.org/papers/w27431",
175
+ "stance": "neutral",
176
+ "reliability": 0.91,
177
+ "source_type": "academic",
178
+ "verification_weight": 0.91
179
+ },
180
+ {
181
+ "id": "src_006",
182
+ "title": "Great Barrington Declaration β€” lockdowns cause more harm than good",
183
+ "text": "Signed by epidemiologists and public health scientists, the declaration argued focused protection for vulnerable groups while allowing low-risk groups to live normally was preferable to blanket lockdowns, citing mental health, economic and healthcare harms.",
184
+ "url": "https://gbdeclaration.org/",
185
+ "stance": "supporting",
186
+ "reliability": 0.72,
187
+ "source_type": "academic",
188
+ "verification_weight": 0.65
189
+ }
190
+ ],
191
+ "ground_truth": "flag",
192
+ "explanation_hint": "The strong claim (lockdowns killed MORE than COVID) is contradicted by excess mortality data. But the underlying concerns about lockdown harms are legitimate and backed by peer-reviewed evidence. Flag as misleading in its absolute framing."
193
+ },
194
+ {
195
+ "id": "hard_003",
196
+ "difficulty": "hard",
197
+ "topic": "financial",
198
+ "tags": ["cryptocurrency", "sec", "regulation", "fraud"],
199
+ "content": "The SEC's crackdown on crypto is not about investor protection β€” it's coordinated suppression of decentralised finance by traditional banking interests who are terrified of losing control of the financial system.",
200
+ "claims": [
201
+ {
202
+ "id": "claim_001",
203
+ "text": "SEC crypto enforcement is primarily driven by banking industry influence",
204
+ "ground_truth_status": "unknown",
205
+ "ground_truth_confidence": 0.50
206
+ },
207
+ {
208
+ "id": "claim_002",
209
+ "text": "SEC crypto actions are not genuinely aimed at investor protection",
210
+ "ground_truth_status": "false",
211
+ "ground_truth_confidence": 0.65
212
+ },
213
+ {
214
+ "id": "claim_003",
215
+ "text": "Traditional banking interests are coordinating to suppress decentralised finance",
216
+ "ground_truth_status": "unknown",
217
+ "ground_truth_confidence": 0.45
218
+ }
219
+ ],
220
+ "sources": [
221
+ {
222
+ "id": "src_001",
223
+ "title": "SEC v. Ripple, SEC v. Coinbase, SEC v. Binance β€” legal filings",
224
+ "text": "SEC enforcement actions against major crypto firms allege specific securities law violations, fraud, and unregistered securities offerings. Court filings cite concrete investor harm rather than systemic suppression of crypto.",
225
+ "url": "https://www.sec.gov/litigation/litreleases/2023/lr25680.htm",
226
+ "stance": "contradicting",
227
+ "reliability": 0.96,
228
+ "source_type": "official",
229
+ "verification_weight": 0.96
230
+ },
231
+ {
232
+ "id": "src_002",
233
+ "title": "Revolving door: Former financial regulators at crypto and banking firms",
234
+ "text": "A ProPublica investigation found significant movement of personnel between SEC leadership, Goldman Sachs, JP Morgan, and crypto firms including Coinbase and FTX, raising questions about regulatory capture.",
235
+ "url": "https://www.propublica.org/article/revolving-door-sec-fintech",
236
+ "stance": "supporting",
237
+ "reliability": 0.82,
238
+ "source_type": "news",
239
+ "verification_weight": 0.78
240
+ },
241
+ {
242
+ "id": "src_003",
243
+ "title": "Congressional Research Service: SEC authority over digital assets",
244
+ "text": "CRS analysis finds the SEC's application of securities law to crypto is legally contested but not inherently illegitimate. Courts have ruled against the SEC in some cases (XRP) and in its favour in others.",
245
+ "url": "https://crsreports.congress.gov/product/pdf/R/R47283",
246
+ "stance": "neutral",
247
+ "reliability": 0.94,
248
+ "source_type": "official",
249
+ "verification_weight": 0.94
250
+ },
251
+ {
252
+ "id": "src_004",
253
+ "title": "FTX collapse: Investor losses from inadequate crypto regulation",
254
+ "text": "The collapse of FTX resulted in an estimated $8 billion of customer losses. Post-collapse investigations found fraud, misuse of customer funds, and the absence of basic financial controls that regulated firms must maintain.",
255
+ "url": "https://www.wsj.com/articles/ftx-collapse-what-happened-11668093732",
256
+ "stance": "contradicting",
257
+ "reliability": 0.89,
258
+ "source_type": "news",
259
+ "verification_weight": 0.89
260
+ },
261
+ {
262
+ "id": "src_005",
263
+ "title": "American Bankers Association lobbying disclosures on crypto regulation",
264
+ "text": "Lobbying disclosures show the ABA and JPMorgan spent millions lobbying for crypto regulation, arguing both for stricter rules on crypto competitors and against CBDC development that could threaten deposit bases.",
265
+ "url": "https://lda.senate.gov/system/public/",
266
+ "stance": "supporting",
267
+ "reliability": 0.85,
268
+ "source_type": "official",
269
+ "verification_weight": 0.82
270
+ },
271
+ {
272
+ "id": "src_006",
273
+ "title": "Crypto critic: Gary Gensler worked at Goldman Sachs",
274
+ "text": "Commentators note SEC Chair Gary Gensler spent years at Goldman Sachs before government service. While he taught blockchain at MIT, his enforcement approach is seen by some as selectively applied.",
275
+ "url": "https://cryptoskeptic.invalid/gensler-goldman",
276
+ "stance": "supporting",
277
+ "reliability": 0.20,
278
+ "source_type": "blog",
279
+ "verification_weight": 0.15
280
+ },
281
+ {
282
+ "id": "src_007",
283
+ "title": "BIS: Regulatory challenges of crypto assets β€” official cross-border analysis",
284
+ "text": "Bank for International Settlements analysis argues crypto requires regulation for financial stability and investor protection, and notes significant regulatory arbitrage risks if jurisdictions fail to coordinate. This is not characterised as suppression.",
285
+ "url": "https://www.bis.org/publ/arpdf/ar2022e3.htm",
286
+ "stance": "contradicting",
287
+ "reliability": 0.95,
288
+ "source_type": "official",
289
+ "verification_weight": 0.95
290
+ }
291
+ ],
292
+ "ground_truth": "flag",
293
+ "explanation_hint": "The conspiracy framing (coordinated suppression) is unsupported, but legitimate regulatory capture concerns and industry lobbying are documented. Investor harm from lax crypto regulation is also well-evidenced. The content oversimplifies a genuinely complex regulatory debate β€” flag."
294
+ },
295
+ {
296
+ "id": "hard_004",
297
+ "difficulty": "hard",
298
+ "topic": "ai_safety",
299
+ "tags": ["ai", "existential_risk", "agi", "safety"],
300
+ "content": "Leading AI researchers have signed open letters warning that AI poses an extinction-level risk to humanity. We should immediately halt all AI development before it is too late.",
301
+ "claims": [
302
+ {
303
+ "id": "claim_001",
304
+ "text": "Leading AI researchers have warned AI poses extinction-level risk",
305
+ "ground_truth_status": "partially_true",
306
+ "ground_truth_confidence": 0.75
307
+ },
308
+ {
309
+ "id": "claim_002",
310
+ "text": "AI development should be immediately halted to prevent extinction",
311
+ "ground_truth_status": "unknown",
312
+ "ground_truth_confidence": 0.50
313
+ }
314
+ ],
315
+ "sources": [
316
+ {
317
+ "id": "src_001",
318
+ "title": "Center for AI Safety: Statement on AI risk signed by Hinton, Bengio, Altman",
319
+ "text": "A statement signed by prominent AI researchers including Geoffrey Hinton, Yoshua Bengio, and Sam Altman states: 'Mitigating the risk of extinction from AI should be a global priority alongside other societal-scale risks.' Over 350 AI researchers signed.",
320
+ "url": "https://www.safe.ai/statement-on-ai-risk",
321
+ "stance": "supporting",
322
+ "reliability": 0.90,
323
+ "source_type": "academic",
324
+ "verification_weight": 0.88
325
+ },
326
+ {
327
+ "id": "src_002",
328
+ "title": "FLI Open Letter: Pause Giant AI Experiments",
329
+ "text": "The Future of Life Institute letter (signed by Elon Musk, Steve Wozniak, and others) called for a 6-month pause on AI training above GPT-4 capability level. Not all signatories believed extinction was imminent β€” concerns ranged from misuse to existential risk.",
330
+ "url": "https://futureoflife.org/open-letter/pause-giant-ai-experiments/",
331
+ "stance": "supporting",
332
+ "reliability": 0.83,
333
+ "source_type": "official",
334
+ "verification_weight": 0.80
335
+ },
336
+ {
337
+ "id": "src_003",
338
+ "title": "Andrew Ng, Yann LeCun: AI doomsday claims are overblown",
339
+ "text": "Several prominent AI researchers including Andrew Ng and Yann LeCun (Meta) have strongly criticised existential risk framings, arguing current AI systems are far from the capabilities required for existential threat and that halting development would harm beneficial applications.",
340
+ "url": "https://www.wired.com/story/openai-microsoft-google-ai-safety-drama/",
341
+ "stance": "contradicting",
342
+ "reliability": 0.85,
343
+ "source_type": "news",
344
+ "verification_weight": 0.83
345
+ },
346
+ {
347
+ "id": "src_004",
348
+ "title": "UK AI Safety Institute: State of AI safety research 2023",
349
+ "text": "The UK's AI Safety Institute notes that risks from AI are serious and warrant dedicated research, but that expert opinion on probability and timeline of existential risk is genuinely divided. Immediate halt is not the consensus recommendation.",
350
+ "url": "https://www.gov.uk/government/publications/ai-safety-institute-overview",
351
+ "stance": "neutral",
352
+ "reliability": 0.93,
353
+ "source_type": "official",
354
+ "verification_weight": 0.93
355
+ },
356
+ {
357
+ "id": "src_005",
358
+ "title": "RAND: Do AI risks justify a halt? Policy analysis",
359
+ "text": "RAND analysis finds halting AI development unilaterally is likely infeasible and would cede advantage to less safety-conscious actors. The report recommends international safety coordination rather than unilateral pause.",
360
+ "url": "https://www.rand.org/pubs/research_reports/RRA2680-1.html",
361
+ "stance": "contradicting",
362
+ "reliability": 0.88,
363
+ "source_type": "official",
364
+ "verification_weight": 0.88
365
+ },
366
+ {
367
+ "id": "src_006",
368
+ "title": "Geoffrey Hinton resignation interview: I genuinely worry about AI risk",
369
+ "text": "Geoffrey Hinton, upon leaving Google, stated he regrets his life's work and is genuinely worried AI could become a major risk to humanity, citing potential for misuse and long-term control problems.",
370
+ "url": "https://www.nytimes.com/2023/05/01/technology/ai-google-chatbot-engineer-quits-hinton.html",
371
+ "stance": "supporting",
372
+ "reliability": 0.88,
373
+ "source_type": "news",
374
+ "verification_weight": 0.87
375
+ }
376
+ ],
377
+ "ground_truth": "flag",
378
+ "explanation_hint": "First claim is supported but the 'extinction-level' framing overstates the open letter language (which says 'should be a priority', not 'is imminent'). The halt recommendation is a contested policy position, not scientific consensus. Flag for misleading framing of a real debate."
379
+ },
380
+ {
381
+ "id": "hard_005",
382
+ "difficulty": "hard",
383
+ "topic": "historical_revisionism",
384
+ "tags": ["history", "colonialism", "reparations"],
385
+ "content": "British colonialism was actually net beneficial for India β€” the railways, legal system, and education infrastructure built by the British gave India the foundations for becoming a modern democracy.",
386
+ "claims": [
387
+ {
388
+ "id": "claim_001",
389
+ "text": "British colonialism built railways and infrastructure in India",
390
+ "ground_truth_status": "true",
391
+ "ground_truth_confidence": 0.95
392
+ },
393
+ {
394
+ "id": "claim_002",
395
+ "text": "British colonialism was net beneficial for India",
396
+ "ground_truth_status": "false",
397
+ "ground_truth_confidence": 0.65
398
+ },
399
+ {
400
+ "id": "claim_003",
401
+ "text": "British rule gave India the foundations for becoming a modern democracy",
402
+ "ground_truth_status": "partially_true",
403
+ "ground_truth_confidence": 0.55
404
+ }
405
+ ],
406
+ "sources": [
407
+ {
408
+ "id": "src_001",
409
+ "title": "Utsa Patnaik: Britain drained $45 trillion from India between 1765 and 1938",
410
+ "text": "Columbia University-published research by economist Utsa Patnaik estimates Britain extracted approximately $45 trillion in current prices from India through taxation, trade manipulation, and forced labour over 173 years of colonial rule.",
411
+ "url": "https://www.columbia.edu/~lmb4/patnaik.pdf",
412
+ "stance": "contradicting",
413
+ "reliability": 0.83,
414
+ "source_type": "academic",
415
+ "verification_weight": 0.80
416
+ },
417
+ {
418
+ "id": "src_002",
419
+ "title": "Shashi Tharoor: Inglorious Empire β€” what the British did to India",
420
+ "text": "Former UN Under-Secretary General and Indian MP Shashi Tharoor argues that British-built railways were designed for resource extraction and troop movement, not Indian development. India's share of global GDP fell from 23% to 4% under British rule.",
421
+ "url": "https://www.theguardian.com/world/2017/mar/08/british-empire-india-shashi-tharoor",
422
+ "stance": "contradicting",
423
+ "reliability": 0.80,
424
+ "source_type": "news",
425
+ "verification_weight": 0.78
426
+ },
427
+ {
428
+ "id": "src_003",
429
+ "title": "Niall Ferguson: Colonialism had positive institutional legacies",
430
+ "text": "Historian Niall Ferguson argues British colonialism transferred institutional knowledge β€” common law, property rights, parliamentary democracy β€” that provided governance foundations still visible in India, Singapore, and Australia.",
431
+ "url": "https://www.nytimes.com/2003/04/27/books/chapters/empire.html",
432
+ "stance": "supporting",
433
+ "reliability": 0.75,
434
+ "source_type": "academic",
435
+ "verification_weight": 0.70
436
+ },
437
+ {
438
+ "id": "src_004",
439
+ "title": "Economic History Review: Did colonialism hold back India's industrialisation?",
440
+ "text": "Peer-reviewed economic history finds India's textile industry was deliberately suppressed by British tariff policy to protect Lancashire mills. The evidence for net economic benefit is contested; most historians find colonial extraction outweighed infrastructure investment.",
441
+ "url": "https://onlinelibrary.wiley.com/journal/14680289",
442
+ "stance": "contradicting",
443
+ "reliability": 0.88,
444
+ "source_type": "academic",
445
+ "verification_weight": 0.88
446
+ },
447
+ {
448
+ "id": "src_005",
449
+ "title": "Bengal Famine 1943: Churchill's role in wartime food policy",
450
+ "text": "Historical records show the 1943 Bengal Famine killed 2-3 million people. Churchill's war cabinet rejected food import requests and diverted food supplies from Bengal. Colonial governance is directly implicated in the death toll.",
451
+ "url": "https://www.theguardian.com/world/2019/sep/17/winston-churchill-and-the-making-of-the-bengal-famine",
452
+ "stance": "contradicting",
453
+ "reliability": 0.85,
454
+ "source_type": "news",
455
+ "verification_weight": 0.85
456
+ },
457
+ {
458
+ "id": "src_006",
459
+ "title": "YouGov poll: 44% of British people proud of the Empire",
460
+ "text": "A YouGov poll found significant British public support for Empire legacy, reflecting a popular revisionist view of colonialism as a civilising mission, though historians note this contradicts dominant scholarly consensus.",
461
+ "url": "https://yougov.co.uk/topics/politics/articles-reports/2020/03/11/how-unique-are-british-attitudes-empire",
462
+ "stance": "supporting",
463
+ "reliability": 0.75,
464
+ "source_type": "news",
465
+ "verification_weight": 0.65
466
+ },
467
+ {
468
+ "id": "src_007",
469
+ "title": "Harvard Growth Lab: Does colonial legacy explain development outcomes?",
470
+ "text": "Comparative analysis finds countries with British common law traditions have some governance advantages, but this does not imply net colonial benefit β€” the counterfactual of independent development is impossible to measure and likely favourable given India's pre-colonial economic position.",
471
+ "url": "https://growthlab.hks.harvard.edu/publications/does-colonial-legacy-explain-development-outcomes",
472
+ "stance": "neutral",
473
+ "reliability": 0.89,
474
+ "source_type": "academic",
475
+ "verification_weight": 0.89
476
+ }
477
+ ],
478
+ "ground_truth": "flag",
479
+ "explanation_hint": "Claim 1 is factually true. Claim 2 is contested β€” most academic consensus finds net harm, but the debate has legitimate scholarly dimensions. Claim 3 is partially true (institutional transfer occurred) but incomplete. Flag as revisionist framing rather than remove β€” historical interpretation is legitimately debated."
480
+ }
481
+ ]
tasks/medium/scenarios.json ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "medium_001",
4
+ "difficulty": "medium",
5
+ "topic": "nutrition_science",
6
+ "tags": ["diet", "sugar", "health"],
7
+ "content": "New study reveals sugar is more addictive than cocaine. Researchers at Princeton found that sugar consumption activates the same brain pathways as hard drugs, confirming what health experts have long suspected.",
8
+ "claims": [
9
+ {
10
+ "id": "claim_001",
11
+ "text": "Sugar is more addictive than cocaine",
12
+ "ground_truth_status": "partially_true",
13
+ "ground_truth_confidence": 0.70
14
+ },
15
+ {
16
+ "id": "claim_002",
17
+ "text": "Princeton researchers confirmed sugar activates the same brain pathways as hard drugs",
18
+ "ground_truth_status": "partially_true",
19
+ "ground_truth_confidence": 0.65
20
+ }
21
+ ],
22
+ "sources": [
23
+ {
24
+ "id": "src_001",
25
+ "title": "Neuroscience & Biobehavioral Reviews: Sugar and addiction β€” a critical review",
26
+ "text": "Animal studies show sugar can produce dopamine release and some withdrawal-like symptoms, but direct comparisons to cocaine addiction in humans are not scientifically established. The Princeton rat studies showed binge-like eating behaviour, not addiction equivalent to hard drugs.",
27
+ "url": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC2235907/",
28
+ "stance": "neutral",
29
+ "reliability": 0.90,
30
+ "source_type": "academic",
31
+ "verification_weight": 0.9
32
+ },
33
+ {
34
+ "id": "src_002",
35
+ "title": "Princeton Press Release: Sugar bingeing in rats",
36
+ "text": "Dr Hoebel's Princeton lab did find that rats with intermittent access to sugar showed dopamine surges and withdrawal-like behaviours. The phrase 'more addictive than cocaine' was not in the original paper β€” it was a media extrapolation.",
37
+ "url": "https://www.princeton.edu/news/2008/12/10/sugar-can-be-addictive-princeton-scientist-says",
38
+ "stance": "neutral",
39
+ "reliability": 0.93,
40
+ "source_type": "official",
41
+ "verification_weight": 0.95
42
+ },
43
+ {
44
+ "id": "src_003",
45
+ "title": "Harvard Health: Is sugar addictive?",
46
+ "text": "While sugar does trigger reward pathways, calling it 'more addictive than cocaine' overstates current evidence. The comparison originated from rat studies and does not directly translate to human addiction medicine.",
47
+ "url": "https://www.health.harvard.edu/blog/is-sugar-addictive-2019052916627",
48
+ "stance": "contradicting",
49
+ "reliability": 0.91,
50
+ "source_type": "official",
51
+ "verification_weight": 0.9
52
+ },
53
+ {
54
+ "id": "src_004",
55
+ "title": "Health magazine: Sugar is the new cocaine",
56
+ "text": "Multiple health experts have linked excessive sugar consumption to compulsive eating behaviours similar to substance addiction, with some calling for sugar to be regulated like alcohol.",
57
+ "url": "https://health-magazine.example/sugar-addiction",
58
+ "stance": "supporting",
59
+ "reliability": 0.55,
60
+ "source_type": "news",
61
+ "verification_weight": 0.5
62
+ },
63
+ {
64
+ "id": "src_005",
65
+ "title": "Frontiers in Psychiatry: Behavioural vs substance addiction β€” the sugar debate",
66
+ "text": "The scientific community is divided. While some researchers classify binge eating of sugar-laden foods as a behavioural addiction with neurological overlap with drug use, direct equivalence to cocaine addiction is not established.",
67
+ "url": "https://www.frontiersin.org/articles/10.3389/fpsyt.2018.00080/full",
68
+ "stance": "neutral",
69
+ "reliability": 0.88,
70
+ "source_type": "academic",
71
+ "verification_weight": 0.88
72
+ },
73
+ {
74
+ "id": "src_006",
75
+ "title": "Viral social post: Sugar more addictive than cocaine confirmed",
76
+ "text": "Mainstream media is hiding how addictive sugar is. Leaked studies show it's worse than heroin. Big Sugar controls the narrative.",
77
+ "url": "https://social.example/sugar-conspiracy",
78
+ "stance": "supporting",
79
+ "reliability": 0.05,
80
+ "source_type": "social",
81
+ "verification_weight": 0.05
82
+ }
83
+ ],
84
+ "ground_truth": "flag",
85
+ "explanation_hint": "The core science has a basis (dopamine activation) but the 'more addictive than cocaine' claim is a significant overstatement. Mixed evidence β€” flag as misleading rather than remove."
86
+ },
87
+ {
88
+ "id": "medium_002",
89
+ "difficulty": "medium",
90
+ "topic": "economic_statistics",
91
+ "tags": ["unemployment", "economy", "statistics"],
92
+ "content": "The government's unemployment figures are a lie. Real unemployment is over 20% when you count all the people who gave up looking for work.",
93
+ "claims": [
94
+ {
95
+ "id": "claim_001",
96
+ "text": "Official unemployment figures exclude discouraged workers",
97
+ "ground_truth_status": "partially_true",
98
+ "ground_truth_confidence": 0.80
99
+ },
100
+ {
101
+ "id": "claim_002",
102
+ "text": "Real unemployment is over 20%",
103
+ "ground_truth_status": "false",
104
+ "ground_truth_confidence": 0.75
105
+ }
106
+ ],
107
+ "sources": [
108
+ {
109
+ "id": "src_001",
110
+ "title": "Bureau of Labor Statistics: How unemployment is measured (U-3 vs U-6)",
111
+ "text": "The BLS publishes multiple unemployment measures. U-3 (headline) excludes marginally attached workers. U-6 (broadest) includes discouraged workers and those working part-time for economic reasons. U-6 is typically 1.5-2x higher than U-3 but has historically remained below 20% except during peak recessions.",
112
+ "url": "https://www.bls.gov/cps/cps_htgm.htm",
113
+ "stance": "neutral",
114
+ "reliability": 0.99,
115
+ "source_type": "official",
116
+ "verification_weight": 1.0
117
+ },
118
+ {
119
+ "id": "src_002",
120
+ "title": "Federal Reserve Economic Data: U-6 unemployment rate historical",
121
+ "text": "U-6 unemployment peaked at 17.2% during the 2008-09 recession and briefly hit 22.9% in April 2020 during COVID lockdowns. Outside those events it has typically ranged from 7-15%.",
122
+ "url": "https://fred.stlouisfed.org/series/U6RATE",
123
+ "stance": "contradicting",
124
+ "reliability": 0.98,
125
+ "source_type": "official",
126
+ "verification_weight": 0.98
127
+ },
128
+ {
129
+ "id": "src_003",
130
+ "title": "Economic Policy Institute: Shadow unemployment analysis",
131
+ "text": "Some economists argue that U-6 still understates labour market slack by excluding workers who want work but haven't searched recently. However, most estimates place true slack well below 20% in normal economic conditions.",
132
+ "url": "https://www.epi.org/publication/unemployment-understated/",
133
+ "stance": "supporting",
134
+ "reliability": 0.82,
135
+ "source_type": "academic",
136
+ "verification_weight": 0.8
137
+ },
138
+ {
139
+ "id": "src_004",
140
+ "title": "Political blog: The 20%+ unemployment truth",
141
+ "text": "Alternative calculations by independent economists suggest real unemployment has exceeded 20% for years. The government manipulates the numbers.",
142
+ "url": "https://economy-truth.invalid/real-unemployment",
143
+ "stance": "supporting",
144
+ "reliability": 0.15,
145
+ "source_type": "blog",
146
+ "verification_weight": 0.1
147
+ },
148
+ {
149
+ "id": "src_005",
150
+ "title": "IMF: US Labour Market Assessment 2023",
151
+ "text": "The IMF's assessment of US labour markets finds the BLS methodology broadly sound and comparable to international standards. The broadest unemployment measures remain well below 20% in non-crisis periods.",
152
+ "url": "https://www.imf.org/en/News/Articles/2023/07/24/us-article-iv-2023",
153
+ "stance": "contradicting",
154
+ "reliability": 0.95,
155
+ "source_type": "official",
156
+ "verification_weight": 0.95
157
+ }
158
+ ],
159
+ "ground_truth": "flag",
160
+ "explanation_hint": "First claim (exclusion of discouraged workers) is technically correct. Second claim (20%+ always) is false. Mixed truth warrants flagging as misleading."
161
+ },
162
+ {
163
+ "id": "medium_003",
164
+ "difficulty": "medium",
165
+ "topic": "technology",
166
+ "tags": ["ai", "job_displacement", "automation"],
167
+ "content": "AI will eliminate 85 million jobs by 2025 according to the World Economic Forum, causing the worst unemployment crisis in human history.",
168
+ "claims": [
169
+ {
170
+ "id": "claim_001",
171
+ "text": "WEF predicted AI will eliminate 85 million jobs by 2025",
172
+ "ground_truth_status": "partially_true",
173
+ "ground_truth_confidence": 0.75
174
+ },
175
+ {
176
+ "id": "claim_002",
177
+ "text": "This will cause the worst unemployment crisis in human history",
178
+ "ground_truth_status": "false",
179
+ "ground_truth_confidence": 0.80
180
+ }
181
+ ],
182
+ "sources": [
183
+ {
184
+ "id": "src_001",
185
+ "title": "WEF Future of Jobs Report 2020: The actual finding",
186
+ "text": "The WEF's 2020 Future of Jobs Report projected that automation may displace 85 million jobs by 2025, but also create 97 million new roles β€” a net positive of 12 million jobs. The report was about transformation, not mass unemployment.",
187
+ "url": "https://www.weforum.org/reports/the-future-of-jobs-report-2020",
188
+ "stance": "neutral",
189
+ "reliability": 0.97,
190
+ "source_type": "official",
191
+ "verification_weight": 1.0
192
+ },
193
+ {
194
+ "id": "src_002",
195
+ "title": "MIT Work of the Future: Technology and jobs β€” nuanced picture",
196
+ "text": "Research from MIT's Work of the Future task force suggests automation creates worker displacement but also new job categories. Historical evidence shows technology creates more jobs than it destroys over the medium term.",
197
+ "url": "https://workofthefuture.mit.edu/",
198
+ "stance": "contradicting",
199
+ "reliability": 0.93,
200
+ "source_type": "academic",
201
+ "verification_weight": 0.93
202
+ },
203
+ {
204
+ "id": "src_003",
205
+ "title": "McKinsey Global Institute: Jobs lost, jobs gained",
206
+ "text": "McKinsey estimates 400-800 million people globally could be displaced by automation by 2030, but predicts most will find new employment. Net job growth historically follows technological revolutions.",
207
+ "url": "https://www.mckinsey.com/featured-insights/future-of-work/jobs-lost-jobs-gained-what-the-future-of-work-will-mean-for-jobs-skills-and-wages",
208
+ "stance": "neutral",
209
+ "reliability": 0.88,
210
+ "source_type": "academic",
211
+ "verification_weight": 0.88
212
+ },
213
+ {
214
+ "id": "src_004",
215
+ "title": "Viral article: AI will destroy all jobs",
216
+ "text": "WEF confirms 85 million jobs gone by 2025. Economists warn this is just the beginning. The end of human employment is near.",
217
+ "url": "https://doomscroll.invalid/ai-jobs-apocalypse",
218
+ "stance": "supporting",
219
+ "reliability": 0.10,
220
+ "source_type": "blog",
221
+ "verification_weight": 0.1
222
+ },
223
+ {
224
+ "id": "src_005",
225
+ "title": "OECD: Employment Outlook β€” Automation and the future of work",
226
+ "text": "OECD research indicates approximately 14% of jobs face high automation risk, while 32% face significant changes. This does not constitute a worst-ever unemployment crisis historically speaking.",
227
+ "url": "https://www.oecd.org/employment/Employment-Outlook-2019-Highlight-EN.pdf",
228
+ "stance": "contradicting",
229
+ "reliability": 0.95,
230
+ "source_type": "official",
231
+ "verification_weight": 0.95
232
+ }
233
+ ],
234
+ "ground_truth": "flag",
235
+ "explanation_hint": "First claim misrepresents WEF (omits the 97M new jobs). Second claim is false (not worst crisis). Selective citation of a real report β€” flag as misleading."
236
+ },
237
+ {
238
+ "id": "medium_004",
239
+ "difficulty": "medium",
240
+ "topic": "pharmaceutical",
241
+ "tags": ["medication", "opioids", "addiction"],
242
+ "content": "Pharmaceutical companies knew OxyContin was highly addictive before launching it, and deliberately misled doctors about addiction risks.",
243
+ "claims": [
244
+ {
245
+ "id": "claim_001",
246
+ "text": "Pharmaceutical companies knew OxyContin was highly addictive before launch",
247
+ "ground_truth_status": "true",
248
+ "ground_truth_confidence": 0.92
249
+ },
250
+ {
251
+ "id": "claim_002",
252
+ "text": "They deliberately misled doctors about addiction risks",
253
+ "ground_truth_status": "true",
254
+ "ground_truth_confidence": 0.88
255
+ }
256
+ ],
257
+ "sources": [
258
+ {
259
+ "id": "src_001",
260
+ "title": "DOJ: Purdue Pharma guilty plea for misleading marketing",
261
+ "text": "Purdue Pharma in 2020 pleaded guilty to federal charges of misleading doctors and the public about OxyContin's addiction risks, including making false statements to the DEA. The company admitted deceptive marketing practices.",
262
+ "url": "https://www.justice.gov/opa/pr/justice-department-announces-global-resolution-criminal-and-civil-investigations-opioid",
263
+ "stance": "supporting",
264
+ "reliability": 0.99,
265
+ "source_type": "official",
266
+ "verification_weight": 1.0
267
+ },
268
+ {
269
+ "id": "src_002",
270
+ "title": "New England Journal of Medicine: The OxyContin crisis β€” documented deception",
271
+ "text": "Peer-reviewed investigation found Purdue Pharma's marketing downplayed addiction risk. Internal documents showed awareness of abuse potential that was not disclosed to prescribers.",
272
+ "url": "https://www.nejm.org/doi/full/10.1056/NEJMsa1706290",
273
+ "stance": "supporting",
274
+ "reliability": 0.97,
275
+ "source_type": "academic",
276
+ "verification_weight": 0.97
277
+ },
278
+ {
279
+ "id": "src_003",
280
+ "title": "Pharma industry response: OxyContin was approved by FDA",
281
+ "text": "Industry representatives point out that OxyContin received FDA approval and that prescribing guidelines were followed. Some critics argue the FDA also bears responsibility for the approval process.",
282
+ "url": "https://phrma.example/oxycontin-response",
283
+ "stance": "contradicting",
284
+ "reliability": 0.40,
285
+ "source_type": "official",
286
+ "verification_weight": 0.3
287
+ },
288
+ {
289
+ "id": "src_004",
290
+ "title": "Reuters: Massachusetts AG lawsuit β€” Sackler family knew of addiction risks in 1996",
291
+ "text": "Court documents show Purdue Pharma executives had internal assessments of OxyContin's addiction potential as early as 1996, a year before the drug's launch.",
292
+ "url": "https://www.reuters.com/article/us-purdue-pharma-lawsuit",
293
+ "stance": "supporting",
294
+ "reliability": 0.91,
295
+ "source_type": "news",
296
+ "verification_weight": 0.92
297
+ },
298
+ {
299
+ "id": "src_005",
300
+ "title": "Congressional Investigation: Purdue Pharma and opioid epidemic",
301
+ "text": "A bipartisan congressional investigation found systemic deception in Purdue Pharma's marketing, including providing false information to state medical boards to increase OxyContin prescriptions.",
302
+ "url": "https://www.congress.gov/committee-report/116th-congress/senate-report/69",
303
+ "stance": "supporting",
304
+ "reliability": 0.97,
305
+ "source_type": "official",
306
+ "verification_weight": 0.97
307
+ }
308
+ ],
309
+ "ground_truth": "allow",
310
+ "explanation_hint": "Both claims are well-documented facts backed by DOJ guilty plea, Congressional investigation, and peer-reviewed evidence. Accurate journalism β€” allow."
311
+ },
312
+ {
313
+ "id": "medium_005",
314
+ "difficulty": "medium",
315
+ "topic": "social_media",
316
+ "tags": ["facebook", "teen_mental_health", "social_media"],
317
+ "content": "Facebook's own internal research proves Instagram is destroying teenagers' mental health and the company hid this evidence from the public.",
318
+ "claims": [
319
+ {
320
+ "id": "claim_001",
321
+ "text": "Facebook's internal research showed Instagram harms teenagers' mental health",
322
+ "ground_truth_status": "partially_true",
323
+ "ground_truth_confidence": 0.80
324
+ },
325
+ {
326
+ "id": "claim_002",
327
+ "text": "Facebook deliberately hid this evidence from the public",
328
+ "ground_truth_status": "partially_true",
329
+ "ground_truth_confidence": 0.70
330
+ }
331
+ ],
332
+ "sources": [
333
+ {
334
+ "id": "src_001",
335
+ "title": "WSJ: Facebook Knows Instagram Is Toxic for Teen Girls, Company Documents Show",
336
+ "text": "Internal Facebook presentations from 2019 and 2020 showed Instagram worsened body image issues in one-third of teen girls surveyed. The company was aware of negative mental health correlations.",
337
+ "url": "https://www.wsj.com/articles/facebook-knows-instagram-is-toxic-for-teen-girls-company-documents-show-11631620739",
338
+ "stance": "supporting",
339
+ "reliability": 0.88,
340
+ "source_type": "news",
341
+ "verification_weight": 0.88
342
+ },
343
+ {
344
+ "id": "src_002",
345
+ "title": "Meta's response to WSJ investigation",
346
+ "text": "Meta stated the WSJ characterisation misrepresented their research, which they said showed mixed results. They argued internal research is preliminary and does not establish causation.",
347
+ "url": "https://about.fb.com/news/2021/09/research-teen-girls-and-instagram/",
348
+ "stance": "contradicting",
349
+ "reliability": 0.70,
350
+ "source_type": "official",
351
+ "verification_weight": 0.6
352
+ },
353
+ {
354
+ "id": "src_003",
355
+ "title": "Senate Commerce Committee: Internal Facebook documents on youth harm",
356
+ "text": "A Senate subcommittee review of internal Facebook documents confirmed the company conducted research linking Instagram to mental health issues in teens and did not make this research publicly available.",
357
+ "url": "https://www.commerce.senate.gov/2021/9/blumenthal-blackburn-investigate-facebook-s-research-on-children-and-instagram",
358
+ "stance": "supporting",
359
+ "reliability": 0.94,
360
+ "source_type": "official",
361
+ "verification_weight": 0.94
362
+ },
363
+ {
364
+ "id": "src_004",
365
+ "title": "Journal of Youth and Adolescence: Systematic review of social media and mental health",
366
+ "text": "A systematic review found small to moderate associations between social media use and depression/anxiety in adolescents, but noted causation vs correlation is difficult to establish. Effects vary significantly by usage patterns.",
367
+ "url": "https://link.springer.com/journal/10964",
368
+ "stance": "neutral",
369
+ "reliability": 0.90,
370
+ "source_type": "academic",
371
+ "verification_weight": 0.9
372
+ },
373
+ {
374
+ "id": "src_005",
375
+ "title": "Antigone Davis (Meta VP): Congressional testimony on teen safety",
376
+ "text": "In congressional testimony, Meta's Global Head of Safety confirmed internal research existed but characterised it as exploratory and noted the company invested significantly in teen safety features.",
377
+ "url": "https://www.commerce.senate.gov/2021/9/protecting-kids-online-facebook-instagram-and-mental-health-harms",
378
+ "stance": "neutral",
379
+ "reliability": 0.88,
380
+ "source_type": "official",
381
+ "verification_weight": 0.85
382
+ }
383
+ ],
384
+ "ground_truth": "flag",
385
+ "explanation_hint": "The core claim is backed by real documents but 'destroying' overstates the research findings, and 'hid' vs 'did not publish' is contested. Flag as partially misleading framing of real events."
386
+ }
387
+ ]
tasks/registry.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tasks/registry.py
3
+ =================
4
+ Deterministic task registry for InformationIntegrityEnv.
5
+
6
+ Loads pre-built scenario datasets from tasks/{difficulty}/scenarios.json.
7
+ NO external API calls β€” all scenarios are static and reproducible.
8
+
9
+ Usage
10
+ -----
11
+ from tasks.registry import TASKS
12
+
13
+ task = TASKS["easy"]
14
+ scenario = task.get_scenario(0) # by index
15
+ scenario = task.get_scenario("easy_001") # by id
16
+ all_sc = task.get_all_scenarios()
17
+
18
+ # Random selection (seeded for reproducibility)
19
+ import random
20
+ rng = random.Random(42)
21
+ scenario = task.sample(rng)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import random
29
+ from typing import Dict, List, Optional
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Path resolution
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _TASKS_DIR = os.path.dirname(os.path.abspath(__file__))
37
+
38
+
39
+ def _load_scenarios(difficulty: str) -> List[dict]:
40
+ path = os.path.join(_TASKS_DIR, difficulty, "scenarios.json")
41
+ if not os.path.exists(path):
42
+ raise FileNotFoundError(
43
+ f"Scenario dataset not found: {path}\n"
44
+ f"Run data_collector.py to build datasets, or check tasks/{difficulty}/scenarios.json"
45
+ )
46
+ with open(path, "r", encoding="utf-8") as fh:
47
+ data = json.load(fh)
48
+ if not isinstance(data, list) or not data:
49
+ raise ValueError(f"scenarios.json for '{difficulty}' must be a non-empty JSON array")
50
+ return data
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Task class
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class Task:
58
+ """
59
+ Wraps one difficulty-level dataset.
60
+
61
+ Parameters
62
+ ----------
63
+ difficulty : str
64
+ One of 'easy', 'medium', 'hard'.
65
+ """
66
+
67
+ def __init__(self, difficulty: str) -> None:
68
+ self.difficulty = difficulty
69
+ self._scenarios: List[dict] = _load_scenarios(difficulty)
70
+ self._index_by_id: Dict[str, dict] = {s["id"]: s for s in self._scenarios}
71
+
72
+ # ------------------------------------------------------------------
73
+ # Public API
74
+ # ------------------------------------------------------------------
75
+
76
+ def get_scenario(self, scenario_id: str | int) -> dict:
77
+ """
78
+ Return a scenario by index (int) or scenario id (str).
79
+ Returns a fresh copy so the env cannot mutate the dataset.
80
+ """
81
+ if isinstance(scenario_id, int):
82
+ if scenario_id < 0 or scenario_id >= len(self._scenarios):
83
+ raise IndexError(
84
+ f"Scenario index {scenario_id} out of range "
85
+ f"(dataset has {len(self._scenarios)} scenarios)"
86
+ )
87
+ return dict(self._scenarios[scenario_id])
88
+ # String id lookup
89
+ if scenario_id not in self._index_by_id:
90
+ raise KeyError(
91
+ f"Scenario id '{scenario_id}' not found in {self.difficulty} dataset. "
92
+ f"Available ids: {list(self._index_by_id.keys())}"
93
+ )
94
+ return dict(self._index_by_id[scenario_id])
95
+
96
+ def get_all_scenarios(self) -> List[dict]:
97
+ """Return all scenarios as a list of dicts (copies)."""
98
+ return [dict(s) for s in self._scenarios]
99
+
100
+ def sample(self, rng: Optional[random.Random] = None) -> dict:
101
+ """Return a uniformly random scenario (optionally seeded)."""
102
+ rng = rng or random.Random()
103
+ return dict(rng.choice(self._scenarios))
104
+
105
+ def __len__(self) -> int:
106
+ return len(self._scenarios)
107
+
108
+ def __repr__(self) -> str:
109
+ return f"Task(difficulty={self.difficulty!r}, n_scenarios={len(self)})"
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Lazy-loaded registry singleton
114
+ # ---------------------------------------------------------------------------
115
+
116
+ class _TaskRegistry:
117
+ """
118
+ Lazy-loading registry. Datasets are only read from disk on first access,
119
+ so importing this module does not cause IO at import time.
120
+ """
121
+
122
+ _cache: Dict[str, Task] = {}
123
+
124
+ _SUPPORTED = ("easy", "medium", "hard")
125
+
126
+ def __getitem__(self, difficulty: str) -> Task:
127
+ key = difficulty.lower().strip()
128
+ if key not in self._SUPPORTED:
129
+ raise KeyError(
130
+ f"Unknown difficulty '{difficulty}'. "
131
+ f"Supported values: {list(self._SUPPORTED)}"
132
+ )
133
+ if key not in self._cache:
134
+ self._cache[key] = Task(key)
135
+ return self._cache[key]
136
+
137
+ def __contains__(self, difficulty: str) -> bool:
138
+ return difficulty.lower().strip() in self._SUPPORTED
139
+
140
+ def keys(self):
141
+ return self._SUPPORTED
142
+
143
+ def items(self):
144
+ return [(d, self[d]) for d in self._SUPPORTED]
145
+
146
+ def reload(self) -> None:
147
+ """Force reload all datasets from disk (useful for tests)."""
148
+ self._cache.clear()
149
+
150
+ def __repr__(self) -> str:
151
+ loaded = list(self._cache.keys())
152
+ return f"TaskRegistry(supported={list(self._SUPPORTED)}, loaded={loaded})"
153
+
154
+
155
+ # Singleton β€” import and use directly: from tasks.registry import TASKS
156
+ TASKS = _TaskRegistry()
test_enhanced_stance_system.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ test_enhanced_stance_system.py
3
+ ==============================
4
+ Comprehensive test suite for the enhanced stance classification system.
5
+
6
+ This script tests all the improvements made to the stance classification system:
7
+ 1. Enhanced text preprocessing
8
+ 2. Multi-template NLI layer
9
+ 3. Domain-specific semantic rules
10
+ 4. Quality-weighted aggregation
11
+ 5. Confidence-based decision making
12
+ """
13
+
14
+ import logging
15
+ import time
16
+ from typing import List, Dict, Any
17
+ import json
18
+
19
+ # Set up logging
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s [%(levelname)s] %(name)s β€” %(message)s"
23
+ )
24
+ logger = logging.getLogger("test_enhanced_stance")
25
+
26
+ def test_enhanced_stance_classifier():
27
+ """Test the enhanced stance classifier with various claim types."""
28
+ print("=" * 60)
29
+ print("TESTING ENHANCED STANCE CLASSIFIER")
30
+ print("=" * 60)
31
+
32
+ try:
33
+ from enhanced_stance_classifier import EnhancedStanceClassifier
34
+ classifier = EnhancedStanceClassifier()
35
+
36
+ # Test cases covering different domains
37
+ test_cases = [
38
+ {
39
+ "claim": "COVID-19 vaccines cause autism",
40
+ "title": "CDC Study on Vaccine Safety",
41
+ "text": "A comprehensive study by the CDC involving over 95,000 children found no evidence linking COVID-19 vaccines to autism. The research, published in a peer-reviewed journal, debunked claims about vaccine-autism connections. Clinical trials and post-market surveillance data consistently show vaccines are safe and effective.",
42
+ "source_reliability": 0.9,
43
+ "expected_stance": "contradicting",
44
+ "domain": "health"
45
+ },
46
+ {
47
+ "claim": "5G towers spread coronavirus",
48
+ "title": "WHO Statement on 5G and COVID-19",
49
+ "text": "The World Health Organization has stated that there is no link between 5G networks and COVID-19 transmission. Viruses cannot spread through radio waves or mobile networks. COVID-19 has spread in many countries that do not have 5G networks. Scientists confirm this is scientifically impossible.",
50
+ "source_reliability": 0.95,
51
+ "expected_stance": "contradicting",
52
+ "domain": "technology"
53
+ },
54
+ {
55
+ "claim": "Climate change is a natural phenomenon",
56
+ "title": "IPCC Climate Report 2023",
57
+ "text": "The Intergovernmental Panel on Climate Change confirms that current climate change is primarily driven by human activities. Scientific consensus shows 97% of climate scientists agree that human activities are the main cause of recent climate change. Temperature records and data analysis support this conclusion.",
58
+ "source_reliability": 0.92,
59
+ "expected_stance": "contradicting",
60
+ "domain": "climate"
61
+ },
62
+ {
63
+ "claim": "Government announces new healthcare policy",
64
+ "title": "Official Government Press Release",
65
+ "text": "The Ministry of Health officially announced a new healthcare policy today. The press release from the government website confirms the implementation of universal healthcare coverage. Official statement from the health minister validates this policy change.",
66
+ "source_reliability": 0.88,
67
+ "expected_stance": "supporting",
68
+ "domain": "politics"
69
+ },
70
+ {
71
+ "claim": "New study shows coffee reduces heart disease risk",
72
+ "title": "Harvard Medical School Research",
73
+ "text": "A peer-reviewed study published in the Journal of the American Heart Association found that moderate coffee consumption is associated with reduced risk of heart disease. The research involved 500,000 participants over 10 years. Clinical evidence shows coffee's antioxidants may protect cardiovascular health.",
74
+ "source_reliability": 0.85,
75
+ "expected_stance": "supporting",
76
+ "domain": "health"
77
+ }
78
+ ]
79
+
80
+ results = []
81
+ for i, test_case in enumerate(test_cases, 1):
82
+ print(f"\nTest Case {i}: {test_case['domain'].upper()} DOMAIN")
83
+ print(f"Claim: {test_case['claim']}")
84
+ print(f"Expected: {test_case['expected_stance']}")
85
+
86
+ start_time = time.time()
87
+ stance, confidence, layer = classifier.classify_detailed(
88
+ title=test_case['title'],
89
+ text=test_case['text'],
90
+ claim=test_case['claim'],
91
+ source_reliability=test_case['source_reliability']
92
+ )
93
+ end_time = time.time()
94
+
95
+ correct = stance == test_case['expected_stance']
96
+ results.append({
97
+ "test_case": i,
98
+ "domain": test_case['domain'],
99
+ "stance": stance,
100
+ "confidence": confidence,
101
+ "layer": layer,
102
+ "expected": test_case['expected_stance'],
103
+ "correct": correct,
104
+ "processing_time": end_time - start_time
105
+ })
106
+
107
+ print(f"Result: {stance} (confidence: {confidence:.3f}, layer: {layer})")
108
+ print(f"Correct: {'βœ“' if correct else 'βœ—'}")
109
+ print(f"Processing time: {end_time - start_time:.3f}s")
110
+
111
+ # Summary
112
+ correct_count = sum(1 for r in results if r['correct'])
113
+ total_count = len(results)
114
+ accuracy = correct_count / total_count
115
+ avg_confidence = sum(r['confidence'] for r in results) / total_count
116
+ avg_time = sum(r['processing_time'] for r in results) / total_count
117
+
118
+ print(f"\n{'='*40}")
119
+ print("ENHANCED STANCE CLASSIFIER RESULTS")
120
+ print(f"{'='*40}")
121
+ print(f"Accuracy: {correct_count}/{total_count} ({accuracy:.1%})")
122
+ print(f"Average Confidence: {avg_confidence:.3f}")
123
+ print(f"Average Processing Time: {avg_time:.3f}s")
124
+
125
+ # Layer usage statistics
126
+ layer_usage = {}
127
+ for result in results:
128
+ layer = result['layer']
129
+ layer_usage[layer] = layer_usage.get(layer, 0) + 1
130
+
131
+ print(f"\nLayer Usage:")
132
+ for layer, count in layer_usage.items():
133
+ print(f" {layer}: {count} ({count/total_count:.1%})")
134
+
135
+ return results
136
+
137
+ except ImportError as e:
138
+ print(f"Enhanced stance classifier not available: {e}")
139
+ return []
140
+
141
+ def test_quality_weighted_aggregator():
142
+ """Test the quality-weighted aggregation system."""
143
+ print("\n" + "=" * 60)
144
+ print("TESTING QUALITY-WEIGHTED AGGREGATOR")
145
+ print("=" * 60)
146
+
147
+ try:
148
+ from quality_weighted_aggregator import (
149
+ QualityWeightedAggregator,
150
+ StanceEvidence,
151
+ AggregationStrategy
152
+ )
153
+
154
+ # Create test evidence
155
+ evidence_list = [
156
+ StanceEvidence(
157
+ stance="contradicting",
158
+ confidence=0.9,
159
+ source_reliability=0.95,
160
+ source_type="official",
161
+ method="semantic",
162
+ text_length=500,
163
+ claim_relevance=0.95
164
+ ),
165
+ StanceEvidence(
166
+ stance="contradicting",
167
+ confidence=0.8,
168
+ source_reliability=0.85,
169
+ source_type="academic",
170
+ method="nli",
171
+ text_length=800,
172
+ claim_relevance=0.9
173
+ ),
174
+ StanceEvidence(
175
+ stance="supporting",
176
+ confidence=0.6,
177
+ source_reliability=0.4,
178
+ source_type="blog",
179
+ method="keyword",
180
+ text_length=200,
181
+ claim_relevance=0.7
182
+ ),
183
+ StanceEvidence(
184
+ stance="neutral",
185
+ confidence=0.5,
186
+ source_reliability=0.6,
187
+ source_type="news",
188
+ method="fallback",
189
+ text_length=300,
190
+ claim_relevance=0.8
191
+ )
192
+ ]
193
+
194
+ # Test different aggregation strategies
195
+ strategies = [
196
+ AggregationStrategy.WEIGHTED_MAJORITY,
197
+ AggregationStrategy.CONFIDENCE_THRESHOLD,
198
+ AggregationStrategy.RELIABILITY_WEIGHTED,
199
+ AggregationStrategy.BAYESIAN_UPDATE,
200
+ AggregationStrategy.ENSEMBLE
201
+ ]
202
+
203
+ results = {}
204
+ for strategy in strategies:
205
+ aggregator = QualityWeightedAggregator(strategy=strategy)
206
+
207
+ start_time = time.time()
208
+ stance, confidence, metadata = aggregator.aggregate_stance_evidence(
209
+ evidence_list,
210
+ claim_text="Test claim about vaccine safety"
211
+ )
212
+ end_time = time.time()
213
+
214
+ # Assess uncertainty
215
+ uncertainty = aggregator.assess_uncertainty(evidence_list)
216
+
217
+ results[strategy.value] = {
218
+ "stance": stance,
219
+ "confidence": confidence,
220
+ "metadata": metadata,
221
+ "uncertainty": uncertainty,
222
+ "processing_time": end_time - start_time
223
+ }
224
+
225
+ print(f"\n{strategy.value.upper()}:")
226
+ print(f" Stance: {stance}")
227
+ print(f" Confidence: {confidence:.3f}")
228
+ print(f" High Uncertainty: {uncertainty['high_uncertainty']}")
229
+ print(f" Processing Time: {end_time - start_time:.4f}s")
230
+
231
+ # Compare results
232
+ print(f"\n{'='*40}")
233
+ print("AGGREGATION STRATEGY COMPARISON")
234
+ print(f"{'='*40}")
235
+
236
+ for strategy_name, result in results.items():
237
+ print(f"{strategy_name:20} | {result['stance']:12} | {result['confidence']:.3f} | {result['processing_time']:.4f}s")
238
+
239
+ return results
240
+
241
+ except ImportError as e:
242
+ print(f"Quality-weighted aggregator not available: {e}")
243
+ return {}
244
+
245
+ def test_text_processing():
246
+ """Test the enhanced text processing capabilities."""
247
+ print("\n" + "=" * 60)
248
+ print("TESTING ENHANCED TEXT PROCESSING")
249
+ print("=" * 60)
250
+
251
+ try:
252
+ from enhanced_stance_classifier import TextProcessor
253
+ processor = TextProcessor()
254
+
255
+ # Test text with various noise patterns
256
+ noisy_text = """
257
+ This is a test article about vaccines. [Editor's note: This is important]
258
+ You can read more at https://example.com/article. @DrSmith says vaccines are safe.
259
+ #VaccinesSafe #HealthNews. The study (published in 2023) shows clear evidence.
260
+
261
+ Multiple spaces and formatting issues here.
262
+
263
+ This is another sentence. And another one! What about questions?
264
+ """
265
+
266
+ print("Original text:")
267
+ print(repr(noisy_text[:200] + "..."))
268
+
269
+ # Test cleaning
270
+ cleaned = processor.clean_text(noisy_text)
271
+ print(f"\nCleaned text:")
272
+ print(repr(cleaned[:200] + "..."))
273
+
274
+ # Test intelligent chunking
275
+ long_text = "This is a sentence. " * 50 # Create long text
276
+ chunks = processor.intelligent_chunk(long_text, max_length=100, overlap=20)
277
+
278
+ print(f"\nChunking test:")
279
+ print(f"Original length: {len(long_text)} characters")
280
+ print(f"Number of chunks: {len(chunks)}")
281
+ print(f"Chunk lengths: {[len(chunk) for chunk in chunks]}")
282
+
283
+ # Test entity extraction
284
+ entity_text = "Dr. John Smith from Harvard Medical School published a study showing 95% effectiveness. The FDA approved this treatment in 2023."
285
+ entities = processor.extract_key_entities(entity_text)
286
+
287
+ print(f"\nEntity extraction test:")
288
+ print(f"Text: {entity_text}")
289
+ print(f"Entities: {entities}")
290
+
291
+ return {
292
+ "cleaned_length": len(cleaned),
293
+ "original_length": len(noisy_text),
294
+ "chunk_count": len(chunks),
295
+ "entities_found": len(entities)
296
+ }
297
+
298
+ except ImportError as e:
299
+ print(f"Text processor not available: {e}")
300
+ return {}
301
+
302
+ def test_data_collector_integration():
303
+ """Test the integration with the enhanced data collector."""
304
+ print("\n" + "=" * 60)
305
+ print("TESTING DATA COLLECTOR INTEGRATION")
306
+ print("=" * 60)
307
+
308
+ try:
309
+ from data_collector import DataCollector, CollectionRequest
310
+
311
+ # Create a simple test request
312
+ request = CollectionRequest(
313
+ content="A post claiming that vaccines cause autism",
314
+ claims=["Vaccines cause autism"],
315
+ difficulty="medium"
316
+ )
317
+
318
+ collector = DataCollector()
319
+
320
+ print("Testing data collector with enhanced stance classification...")
321
+ print(f"Claim: {request.claims[0]}")
322
+ print(f"Available backends: {[b.name for b in collector._backends]}")
323
+
324
+ # Note: This would require API keys to actually collect data
325
+ # For testing purposes, we'll just verify the collector is properly configured
326
+ print(f"Stance classifier type: {type(collector._stancer).__name__}")
327
+ print(f"Has classify_detailed method: {hasattr(collector._stancer, 'classify_detailed')}")
328
+
329
+ return {
330
+ "collector_configured": True,
331
+ "enhanced_classifier": hasattr(collector._stancer, 'classify_detailed'),
332
+ "backend_count": len(collector._backends)
333
+ }
334
+
335
+ except ImportError as e:
336
+ print(f"Data collector not available: {e}")
337
+ return {}
338
+
339
+ def run_performance_benchmark():
340
+ """Run performance benchmarks on the enhanced system."""
341
+ print("\n" + "=" * 60)
342
+ print("PERFORMANCE BENCHMARK")
343
+ print("=" * 60)
344
+
345
+ try:
346
+ from enhanced_stance_classifier import EnhancedStanceClassifier
347
+
348
+ classifier = EnhancedStanceClassifier()
349
+
350
+ # Benchmark data
351
+ benchmark_cases = [
352
+ ("Short claim", "Vaccines are safe", "Short text about vaccine safety from CDC", 0.8),
353
+ ("Medium claim", "Climate change is caused by human activities and poses significant risks",
354
+ "A comprehensive study by climate scientists shows overwhelming evidence that human activities, particularly greenhouse gas emissions, are the primary driver of current climate change. The research, published in multiple peer-reviewed journals, demonstrates clear correlations between industrial activity and global temperature rise." * 2, 0.9),
355
+ ("Long claim", "The effectiveness of various COVID-19 vaccines in preventing severe illness and hospitalization",
356
+ "Multiple large-scale clinical trials and real-world studies have demonstrated the effectiveness of COVID-19 vaccines in preventing severe illness, hospitalization, and death. Data from health agencies worldwide consistently show that vaccinated individuals have significantly lower rates of severe COVID-19 outcomes compared to unvaccinated individuals. The vaccines have undergone rigorous testing and continue to be monitored for safety and efficacy." * 5, 0.85)
357
+ ]
358
+
359
+ results = []
360
+ for case_name, claim, text, reliability in benchmark_cases:
361
+ times = []
362
+ for _ in range(5): # Run each test 5 times
363
+ start_time = time.time()
364
+ stance, confidence, layer = classifier.classify_detailed(
365
+ title="Test Title",
366
+ text=text,
367
+ claim=claim,
368
+ source_reliability=reliability
369
+ )
370
+ end_time = time.time()
371
+ times.append(end_time - start_time)
372
+
373
+ avg_time = sum(times) / len(times)
374
+ min_time = min(times)
375
+ max_time = max(times)
376
+
377
+ results.append({
378
+ "case": case_name,
379
+ "claim_length": len(claim),
380
+ "text_length": len(text),
381
+ "avg_time": avg_time,
382
+ "min_time": min_time,
383
+ "max_time": max_time,
384
+ "stance": stance,
385
+ "confidence": confidence,
386
+ "layer": layer
387
+ })
388
+
389
+ print(f"{case_name}:")
390
+ print(f" Claim length: {len(claim)} chars")
391
+ print(f" Text length: {len(text)} chars")
392
+ print(f" Avg time: {avg_time:.4f}s")
393
+ print(f" Range: {min_time:.4f}s - {max_time:.4f}s")
394
+ print(f" Result: {stance} ({confidence:.3f}, {layer})")
395
+ print()
396
+
397
+ return results
398
+
399
+ except ImportError as e:
400
+ print(f"Performance benchmark not available: {e}")
401
+ return []
402
+
403
+ def main():
404
+ """Run all tests and generate a comprehensive report."""
405
+ print("ENHANCED STANCE CLASSIFICATION SYSTEM TEST SUITE")
406
+ print("=" * 60)
407
+ print(f"Test started at: {time.strftime('%Y-%m-%d %H:%M:%S')}")
408
+
409
+ # Run all tests
410
+ test_results = {}
411
+
412
+ # Test 1: Enhanced Stance Classifier
413
+ test_results['stance_classifier'] = test_enhanced_stance_classifier()
414
+
415
+ # Test 2: Quality-Weighted Aggregator
416
+ test_results['aggregator'] = test_quality_weighted_aggregator()
417
+
418
+ # Test 3: Text Processing
419
+ test_results['text_processing'] = test_text_processing()
420
+
421
+ # Test 4: Data Collector Integration
422
+ test_results['data_collector'] = test_data_collector_integration()
423
+
424
+ # Test 5: Performance Benchmark
425
+ test_results['performance'] = run_performance_benchmark()
426
+
427
+ # Generate summary report
428
+ print("\n" + "=" * 60)
429
+ print("COMPREHENSIVE TEST SUMMARY")
430
+ print("=" * 60)
431
+
432
+ if test_results['stance_classifier']:
433
+ stance_results = test_results['stance_classifier']
434
+ accuracy = sum(1 for r in stance_results if r['correct']) / len(stance_results)
435
+ avg_confidence = sum(r['confidence'] for r in stance_results) / len(stance_results)
436
+ print(f"βœ“ Enhanced Stance Classifier: {accuracy:.1%} accuracy, {avg_confidence:.3f} avg confidence")
437
+ else:
438
+ print("βœ— Enhanced Stance Classifier: Not available")
439
+
440
+ if test_results['aggregator']:
441
+ print("βœ“ Quality-Weighted Aggregator: All strategies tested successfully")
442
+ else:
443
+ print("βœ— Quality-Weighted Aggregator: Not available")
444
+
445
+ if test_results['text_processing']:
446
+ tp_results = test_results['text_processing']
447
+ print(f"βœ“ Text Processing: {tp_results.get('chunk_count', 0)} chunks, {tp_results.get('entities_found', 0)} entities extracted")
448
+ else:
449
+ print("βœ— Text Processing: Not available")
450
+
451
+ if test_results['data_collector']:
452
+ dc_results = test_results['data_collector']
453
+ enhanced = "Enhanced" if dc_results.get('enhanced_classifier') else "Basic"
454
+ print(f"βœ“ Data Collector Integration: {enhanced} classifier, {dc_results.get('backend_count', 0)} backends")
455
+ else:
456
+ print("βœ— Data Collector Integration: Not available")
457
+
458
+ if test_results['performance']:
459
+ perf_results = test_results['performance']
460
+ avg_time = sum(r['avg_time'] for r in perf_results) / len(perf_results)
461
+ print(f"βœ“ Performance Benchmark: {avg_time:.4f}s average processing time")
462
+ else:
463
+ print("βœ— Performance Benchmark: Not available")
464
+
465
+ print(f"\nTest completed at: {time.strftime('%Y-%m-%d %H:%M:%S')}")
466
+
467
+ # Save detailed results to file
468
+ try:
469
+ with open('test_results.json', 'w') as f:
470
+ json.dump(test_results, f, indent=2, default=str)
471
+ print("Detailed results saved to test_results.json")
472
+ except Exception as e:
473
+ print(f"Could not save results: {e}")
474
+
475
+ return test_results
476
+
477
+ if __name__ == "__main__":
478
+ main()
ui.py ADDED
@@ -0,0 +1,683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui.py β€” Information Integrity Analyzer
3
+ One input. Automatic pipeline. Rich verdict output.
4
+ """
5
+
6
+ # Load .env first so all API keys are available before any module reads os.getenv()
7
+ try:
8
+ from dotenv import load_dotenv
9
+ load_dotenv()
10
+ except ImportError:
11
+ pass
12
+
13
+ import re
14
+ import time
15
+ from typing import Generator
16
+
17
+ import gradio as gr
18
+
19
+ from data_collector import CollectionRequest, DataCollector
20
+ from environment import InformationIntegrityEnv
21
+ from explainability import ExplainabilityEngine, build_reasoning_from_episode
22
+ from models import Action
23
+ from tasks.graders import GraderFactory
24
+
25
+ try:
26
+ from reasoning import DeterministicReasoningEngine, reason_over_claims
27
+ from retrieval import RetrievalPipeline
28
+ from decision_engine import make_moderation_decisions
29
+ _REASONING_AVAILABLE = True
30
+ except Exception:
31
+ _REASONING_AVAILABLE = False
32
+
33
+ collector = DataCollector()
34
+ explainer = ExplainabilityEngine()
35
+
36
+ # ─────────────────────────────────────────────────────────────────────────────
37
+ # Claim extractor
38
+ # ─────────────────────────────────────────────────────────────────────────────
39
+ _SIGNALS = re.compile(
40
+ r"\b(confirm|prove|show|reveal|say|claim|report|find|cause|lead to|linked to|"
41
+ r"associated with|increase|decrease|never|always|all|every|none|proven|fact|"
42
+ r"truth|confirmed|vaccine|virus|cancer|disease|drug|radiation|5g|covid|"
43
+ r"lockdown|percent|%|study|research|scientist|doctor|expert|government|"
44
+ r"permanently|alter|spread|hide|secret|conspiracy|banned|cure|prevent)\b",
45
+ re.IGNORECASE,
46
+ )
47
+ _SKIP = re.compile(
48
+ r"^(share|follow|click|subscribe|watch|read|download|buy|sign up|please|"
49
+ r"thank you|breaking:|update:|alert:|warning:)",
50
+ re.IGNORECASE,
51
+ )
52
+
53
+
54
+ def extract_claims(text: str) -> list[str]:
55
+ sentences = re.split(r"(?<=[.!?])\s+|\s*[|‒–—]\s*", text.strip())
56
+ claims, seen = [], set()
57
+ for s in sentences:
58
+ s = s.strip().strip("\"'.,!?")
59
+ if len(s) < 12 or len(s) > 280:
60
+ continue
61
+ if _SKIP.match(s):
62
+ continue
63
+ if _SIGNALS.search(s) and s.lower() not in seen:
64
+ seen.add(s.lower())
65
+ claims.append(s)
66
+ if not claims:
67
+ claims = [s.strip() for s in sentences if len(s.strip()) >= 15][:3]
68
+ return claims[:6]
69
+
70
+
71
+ # ─────────────────────────────────────────────────────────────────────────────
72
+ # HTML card builders
73
+ # ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ def _progress_html(stage: str, pct: int, msg: str) -> str:
76
+ stages = ["Extracting claims", "Searching sources", "Running analysis", "Complete"]
77
+ dots = "".join(
78
+ f'<span style="display:inline-block;width:10px;height:10px;border-radius:50%;'
79
+ f'margin:0 4px;background:{"#4f8ef7" if i <= stages.index(stage) else "#dde3ee"}'
80
+ f'"></span>'
81
+ for i, s in enumerate(stages)
82
+ if (i <= stages.index(stage)) or True
83
+ )
84
+ return f"""
85
+ <div style="font-family:'Inter',sans-serif;padding:28px 32px;background:#f8faff;
86
+ border-radius:16px;border:1px solid #e0e7ff;">
87
+ <div style="font-size:15px;color:#5c6bc0;margin-bottom:14px;font-weight:600;">
88
+ {stage}… &nbsp; <span style="font-weight:400;color:#8e99c5">{msg}</span>
89
+ </div>
90
+ <div style="background:#e8edf8;border-radius:8px;height:8px;overflow:hidden;">
91
+ <div style="background:linear-gradient(90deg,#4f8ef7,#7c6af7);height:100%;
92
+ width:{pct}%;transition:width 0.4s ease;border-radius:8px;"></div>
93
+ </div>
94
+ </div>"""
95
+
96
+
97
+ def _verdict_color(decision: str) -> tuple[str, str, str]:
98
+ """Returns (bg_gradient, text_color, icon)."""
99
+ return {
100
+ "remove": ("linear-gradient(135deg,#ff4444,#d32f2f)", "#fff", "🚫"),
101
+ "flag": ("linear-gradient(135deg,#ff9800,#e65100)", "#fff", "⚠️"),
102
+ "allow": ("linear-gradient(135deg,#43a047,#1b5e20)", "#fff", "βœ…"),
103
+ }.get(decision, ("linear-gradient(135deg,#607d8b,#37474f)", "#fff", "❓"))
104
+
105
+
106
+ def _reliability_bar(score: float) -> str:
107
+ filled = int(score * 12)
108
+ color = "#e53935" if score < 0.4 else "#fb8c00" if score < 0.7 else "#43a047"
109
+ bar = f'<span style="color:{color}">{"β–ˆ"*filled}</span>' \
110
+ f'<span style="color:#e0e0e0">{"β–ˆ"*(12-filled)}</span>'
111
+ return f'<span style="font-family:monospace;font-size:12px">{bar}</span> ' \
112
+ f'<span style="font-size:12px;color:#666">{score:.2f}</span>'
113
+
114
+
115
+ def _stance_badge(stance: str) -> str:
116
+ cfg = {
117
+ "contradicting": ("#fdecea", "#c62828", "βœ— Contradicts"),
118
+ "supporting": ("#e8f5e9", "#2e7d32", "βœ“ Supports"),
119
+ "neutral": ("#f3f4f6", "#555", "Β· Neutral"),
120
+ }.get(stance, ("#f3f4f6", "#555", stance))
121
+ bg, fg, label = cfg
122
+ return (f'<span style="background:{bg};color:{fg};padding:2px 8px;'
123
+ f'border-radius:12px;font-size:12px;font-weight:600">{label}</span>')
124
+
125
+
126
+ def _claim_badge(status: str) -> str:
127
+ cfg = {
128
+ "false": ("#fdecea", "#c62828", "FALSE"),
129
+ "true": ("#e8f5e9", "#2e7d32", "TRUE"),
130
+ "partially_true": ("#fff8e1", "#e65100", "PARTIAL"),
131
+ "unknown": ("#f3f4f6", "#555", "UNKNOWN"),
132
+ "unverified": ("#f3f4f6", "#9e9e9e", "UNVERIFIED"),
133
+ }.get(status.lower() if isinstance(status, str) else "unverified",
134
+ ("#f3f4f6", "#555", status))
135
+ bg, fg, label = cfg
136
+ return (f'<span style="background:{bg};color:{fg};padding:3px 10px;'
137
+ f'border-radius:12px;font-size:11px;font-weight:700;'
138
+ f'letter-spacing:.5px">{label}</span>')
139
+
140
+
141
+ def build_result_html(statement: str, scenario: dict, obs, result, explanation: str,
142
+ per_claim_reasoning: dict | None = None) -> str:
143
+ decision = result.decision or "unknown"
144
+ grad, txt_col, icon = _verdict_color(decision)
145
+ correct_badge = (
146
+ '<span style="background:#e8f5e9;color:#2e7d32;padding:3px 10px;'
147
+ 'border-radius:12px;font-size:12px;font-weight:600">βœ“ Correct</span>'
148
+ if result.correct else
149
+ '<span style="background:#fdecea;color:#c62828;padding:3px 10px;'
150
+ 'border-radius:12px;font-size:12px;font-weight:600">βœ— Incorrect</span>'
151
+ )
152
+
153
+ # ── Claims section
154
+ claims_html = ""
155
+ for c in obs.claims:
156
+ status = c.verification_status
157
+ if hasattr(status, "value"):
158
+ status = status.value
159
+ conf_pct = int(c.confidence * 100)
160
+ claims_html += f"""
161
+ <div style="display:flex;align-items:flex-start;gap:12px;padding:12px 0;
162
+ border-bottom:1px solid #f0f0f0;">
163
+ <div style="flex:1;font-size:14px;color:#333;line-height:1.5">{c.text}</div>
164
+ <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;
165
+ white-space:nowrap">
166
+ {_claim_badge(status)}
167
+ <span style="font-size:11px;color:#999">conf {conf_pct}%</span>
168
+ </div>
169
+ </div>"""
170
+
171
+ # ── Sources section (top 6 by reliability)
172
+ sources = sorted(scenario["sources"], key=lambda s: -s["reliability"])[:6]
173
+ sources_html = ""
174
+ for s in sources:
175
+ domain = re.sub(r"https?://(www\.)?", "", s["url"]).split("/")[0]
176
+ sources_html += f"""
177
+ <div style="padding:12px;border-radius:10px;background:#fafafa;
178
+ border:1px solid #eee;margin-bottom:8px">
179
+ <div style="display:flex;justify-content:space-between;align-items:center;
180
+ flex-wrap:wrap;gap:6px">
181
+ <span style="font-size:13px;font-weight:600;color:#333">{s['title'][:65]}</span>
182
+ {_stance_badge(s['stance'])}
183
+ </div>
184
+ <div style="font-size:12px;color:#888;margin:4px 0">{domain} Β· {s['source_type']}</div>
185
+ <div style="font-size:12px;color:#555;line-height:1.5;margin:6px 0">
186
+ {s['text'][:180]}{'…' if len(s['text'])>180 else ''}
187
+ </div>
188
+ <div>Reliability: {_reliability_bar(s['reliability'])}</div>
189
+ </div>"""
190
+
191
+ if not sources_html:
192
+ sources_html = '<p style="color:#999;font-style:italic">No sources fetched β€” try adding API keys for richer results.</p>'
193
+
194
+ # ── Reward breakdown
195
+ bd = result.reward_breakdown
196
+ reward_rows = ""
197
+ for label, val in [
198
+ ("Claim verification", bd.verify_claim_reward),
199
+ ("Source checks", bd.check_source_reward),
200
+ ("Multi-source bonus", bd.multi_source_bonus),
201
+ ("Decision accuracy", bd.decision_reward),
202
+ ("Premature penalty", bd.premature_penalty),
203
+ ("Redundancy penalty", bd.redundant_penalty),
204
+ ("Inefficiency", bd.inefficiency_penalty),
205
+ ("Over-aggression", bd.aggression_penalty),
206
+ ]:
207
+ if val == 0:
208
+ continue
209
+ color = "#2e7d32" if val > 0 else "#c62828"
210
+ reward_rows += f"""
211
+ <div style="display:flex;justify-content:space-between;padding:5px 0;
212
+ border-bottom:1px solid #f5f5f5;font-size:13px">
213
+ <span style="color:#555">{label}</span>
214
+ <span style="color:{color};font-weight:600">{val:+.2f}</span>
215
+ </div>"""
216
+
217
+ # ── Grader sub-scores
218
+ grader = GraderFactory.get(scenario["difficulty"])
219
+ gbd = grader.score_breakdown(
220
+ decision = result.decision or "none",
221
+ ground_truth = result.ground_truth,
222
+ sources_checked = list(range(result.sources_checked)),
223
+ claims_verified = list(range(result.claims_verified)),
224
+ step_count = result.steps_taken,
225
+ all_source_ids = [s["id"] for s in scenario["sources"]],
226
+ all_claim_ids = [c["id"] for c in scenario["claims"]],
227
+ )
228
+ grader_bars = ""
229
+ for sub_label, sub_val in [
230
+ ("Decision", gbd["decision_sub_score"]),
231
+ ("Evidence", gbd["evidence_sub_score"]),
232
+ ("Reasoning", gbd["reasoning_sub_score"]),
233
+ ("Efficiency", gbd["efficiency_sub_score"]),
234
+ ]:
235
+ pct = int(sub_val * 100)
236
+ colr = "#e53935" if pct < 50 else "#fb8c00" if pct < 75 else "#43a047"
237
+ grader_bars += f"""
238
+ <div style="margin-bottom:8px">
239
+ <div style="display:flex;justify-content:space-between;font-size:12px;
240
+ color:#555;margin-bottom:3px">
241
+ <span>{sub_label}</span><span style="color:{colr};font-weight:600">{pct}%</span>
242
+ </div>
243
+ <div style="background:#eee;border-radius:4px;height:6px">
244
+ <div style="background:{colr};width:{pct}%;height:100%;border-radius:4px"></div>
245
+ </div>
246
+ </div>"""
247
+
248
+ # ── Deterministic Reasoning breakdown (new)
249
+ reasoning_html = ""
250
+ if per_claim_reasoning:
251
+ reasoning_html = """
252
+ <div style="background:#fff;border-radius:14px;border:1px solid #e8ecf4;
253
+ padding:20px 24px;margin-bottom:20px">
254
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
255
+ text-transform:uppercase;margin-bottom:14px">
256
+ 🧠 Deterministic Reasoning Engine
257
+ </div>"""
258
+ for claim_text, r in per_claim_reasoning.items():
259
+ score = r["evidence_score"]
260
+ conf = r["confidence"]
261
+ dec = r["decision"]
262
+ n_sup = r["n_supporting"]
263
+ n_con = r["n_contradicting"]
264
+ n_neu = r["n_neutral"]
265
+ dec_col = {"allow": "#2e7d32", "flag": "#e65100", "remove": "#c62828"}.get(dec, "#555")
266
+ score_col = "#c62828" if score < -0.3 else "#2e7d32" if score > 0.3 else "#e65100"
267
+ reasoning_html += f"""
268
+ <div style="background:#f8faff;border-radius:10px;padding:14px 18px;margin-bottom:12px;
269
+ border-left:4px solid {dec_col}">
270
+ <div style="font-size:13px;color:#444;font-style:italic;margin-bottom:8px">
271
+ "{claim_text[:100]}{'…' if len(claim_text)>100 else ''}"
272
+ </div>
273
+ <div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
274
+ <span style="background:{dec_col};color:#fff;padding:2px 10px;border-radius:10px;
275
+ font-size:12px;font-weight:700;text-transform:uppercase">{dec}</span>
276
+ <span style="font-size:12px;color:{score_col};font-weight:600">
277
+ Score: {score:+.3f}</span>
278
+ <span style="font-size:12px;color:#888">Confidence: {conf}</span>
279
+ <span style="font-size:12px;color:#2e7d32">β–² {n_sup} supporting</span>
280
+ <span style="font-size:12px;color:#c62828">β–Ό {n_con} contradicting</span>
281
+ <span style="font-size:12px;color:#888">Β· {n_neu} neutral</span>
282
+ </div>"""
283
+ # Safety rules fired
284
+ if r.get("safety_rules_fired"):
285
+ for rule in r["safety_rules_fired"]:
286
+ rule_name = rule.split(":")[0]
287
+ rule_desc = rule[len(rule_name)+1:].strip()
288
+ reasoning_html += f"""
289
+ <div style="font-size:11px;color:#e65100;background:#fff8e1;padding:4px 10px;
290
+ border-radius:6px;margin-bottom:4px">
291
+ ⚠ <b>{rule_name}</b>: {rule_desc}
292
+ </div>"""
293
+ # Source breakdown (top 4)
294
+ sb = r.get("source_breakdown", [])[:4]
295
+ if sb:
296
+ reasoning_html += '<div style="margin-top:8px">'
297
+ for src in sb:
298
+ contrib = src["contribution"]
299
+ c_col = "#c62828" if contrib < 0 else "#2e7d32" if contrib > 0 else "#888"
300
+ reasoning_html += f"""
301
+ <div style="display:flex;justify-content:space-between;font-size:11px;
302
+ color:#555;padding:2px 0;border-bottom:1px solid #eee">
303
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
304
+ max-width:420px">{src['title'][:70]}</span>
305
+ <span style="margin:0 8px">{_stance_badge(src['stance'])}</span>
306
+ <span style="font-weight:600;color:{c_col};white-space:nowrap">
307
+ {contrib:+.3f}</span>
308
+ </div>"""
309
+ reasoning_html += "</div>"
310
+ reasoning_html += "</div>"
311
+ reasoning_html += "</div>"
312
+
313
+ # ── Explanation (pre-formatted)
314
+ expl_lines = explanation.replace("<", "&lt;").replace(">", "&gt;")
315
+ expl_html = f'<pre style="font-family:inherit;font-size:13px;line-height:1.7;' \
316
+ f'white-space:pre-wrap;margin:0;color:#333">{expl_lines}</pre>'
317
+
318
+ return f"""
319
+ <div style="font-family:'Inter',system-ui,sans-serif;max-width:960px;margin:0 auto">
320
+
321
+ <!-- VERDICT BANNER -->
322
+ <div style="background:{grad};border-radius:16px;padding:28px 32px;
323
+ margin-bottom:24px;display:flex;align-items:center;gap:20px">
324
+ <div style="font-size:52px">{icon}</div>
325
+ <div>
326
+ <div style="color:rgba(255,255,255,.75);font-size:13px;
327
+ font-weight:600;letter-spacing:1px;text-transform:uppercase">
328
+ Moderation Verdict
329
+ </div>
330
+ <div style="color:{txt_col};font-size:36px;font-weight:800;
331
+ line-height:1.1;text-transform:uppercase">{decision}</div>
332
+ <div style="color:rgba(255,255,255,.85);font-size:13px;margin-top:4px">
333
+ Difficulty: <b>{scenario['difficulty']}</b> &nbsp;Β·&nbsp;
334
+ Steps: <b>{result.steps_taken}</b> &nbsp;Β·&nbsp;
335
+ Reward: <b>{result.total_reward:+.2f}</b> &nbsp;Β·&nbsp;
336
+ Grader: <b>{result.grader_score:.0%}</b>
337
+ &nbsp;&nbsp;{correct_badge}
338
+ </div>
339
+ </div>
340
+ </div>
341
+
342
+ <!-- STATEMENT -->
343
+ <div style="background:#fff;border-radius:14px;border:1px solid #e8ecf4;
344
+ padding:20px 24px;margin-bottom:20px">
345
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
346
+ text-transform:uppercase;margin-bottom:8px">Statement Reviewed</div>
347
+ <div style="font-size:15px;color:#1f2937;line-height:1.6;
348
+ font-style:italic">"{statement}"</div>
349
+ </div>
350
+
351
+ <!-- CLAIMS + SOURCES (two col) -->
352
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px">
353
+
354
+ <!-- Claims -->
355
+ <div style="background:#fff;border-radius:14px;border:1px solid #e8ecf4;
356
+ padding:20px 24px">
357
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
358
+ text-transform:uppercase;margin-bottom:12px">
359
+ Claims Verified ({len(obs.claims)})
360
+ </div>
361
+ {claims_html}
362
+ </div>
363
+
364
+ <!-- Scores -->
365
+ <div style="background:#fff;border-radius:14px;border:1px solid #e8ecf4;
366
+ padding:20px 24px">
367
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
368
+ text-transform:uppercase;margin-bottom:14px">Grader Score Breakdown</div>
369
+ {grader_bars}
370
+ <div style="border-top:1px solid #eee;padding-top:12px;margin-top:4px">
371
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
372
+ text-transform:uppercase;margin-bottom:8px">Reward Breakdown</div>
373
+ {reward_rows}
374
+ <div style="display:flex;justify-content:space-between;padding:8px 0;
375
+ font-size:14px;font-weight:700">
376
+ <span>Total Reward</span>
377
+ <span style="color:{'#2e7d32' if result.total_reward>0 else '#c62828'}">
378
+ {result.total_reward:+.2f}
379
+ </span>
380
+ </div>
381
+ </div>
382
+ </div>
383
+ </div>
384
+
385
+ <!-- SOURCES -->
386
+ <div style="background:#fff;border-radius:14px;border:1px solid #e8ecf4;
387
+ padding:20px 24px;margin-bottom:20px">
388
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
389
+ text-transform:uppercase;margin-bottom:14px">
390
+ Evidence Sources ({len(scenario['sources'])} found)
391
+ </div>
392
+ {sources_html}
393
+ </div>
394
+
395
+ {reasoning_html}
396
+
397
+ <!-- EXPLANATION -->
398
+ <div style="background:#fff;border-radius:14px;border:1px solid #e8ecf4;
399
+ padding:20px 24px">
400
+ <div style="font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1px;
401
+ text-transform:uppercase;margin-bottom:14px">
402
+ πŸ“‹ Full Reasoning & Explanation
403
+ </div>
404
+ <div style="background:#f8faff;border-radius:10px;padding:16px 20px">
405
+ {expl_html}
406
+ </div>
407
+ </div>
408
+
409
+ </div>
410
+ """
411
+
412
+
413
+ # ─────────────────────────────────────────────────────────────────────────────
414
+ # Main pipeline (streaming generator)
415
+ # ─────────────────────────────────────────────────────────────────────────────
416
+
417
+ def analyze(statement: str) -> Generator:
418
+ """
419
+ Difficulty is NOT a UI input.
420
+ The UI is a demo tool; evaluation difficulty is controlled exclusively
421
+ by the backend API (/reset?difficulty=...) and inference.py.
422
+ DataCollector always uses 'medium' here β€” it only affects evidence
423
+ retrieval depth, not grading.
424
+ """
425
+ statement = statement.strip()
426
+ if not statement:
427
+ yield "<p style='color:#c62828;padding:20px'>⚠️ Please enter a statement to analyze.</p>"
428
+ return
429
+
430
+ # Stage 1 β€” Extract claims
431
+ yield _progress_html("Extracting claims", 10, "detecting verifiable assertions…")
432
+ claims = extract_claims(statement)
433
+ if not claims:
434
+ claims = [statement]
435
+ claim_list = " β€’ ".join(f"<em>{c[:60]}</em>" for c in claims)
436
+ yield _progress_html("Searching sources", 30,
437
+ f"found {len(claims)} claim(s): {claim_list[:120]}…")
438
+
439
+ # Stage 2 β€” Collect evidence
440
+ # Difficulty is fixed to "medium" here β€” the UI does NOT control evaluation logic.
441
+ # To run a proper benchmark, use: python inference.py --benchmark
442
+ try:
443
+ req = CollectionRequest(content=statement, claims=claims,
444
+ difficulty="medium")
445
+ scenario = collector.collect(req)
446
+ except Exception as e:
447
+ yield f"<p style='color:#c62828;padding:20px'>❌ Evidence collection failed: {e}</p>"
448
+ return
449
+
450
+ n_src = len(scenario["sources"])
451
+ yield _progress_html("Running analysis", 65,
452
+ f"fetched {n_src} source(s) β€” running moderation episode…")
453
+
454
+ # Stage 3 β€” Run episode
455
+ env = InformationIntegrityEnv(scenario=scenario, max_steps=20)
456
+ obs = env.reset()
457
+ per_claim_reasoning = None # populated by reasoning engine at decide step
458
+
459
+ # Auto-agent: verify all claims β†’ check all sources β†’ decide
460
+ for claim in obs.claims:
461
+ try:
462
+ obs, _, done, _ = env.step(
463
+ Action(type="verify_claim", claim_id=claim.id,
464
+ reasoning="auto-verify"))
465
+ if done:
466
+ break
467
+ except Exception:
468
+ pass
469
+
470
+ if not env.done:
471
+ for src in obs.available_sources:
472
+ try:
473
+ obs, _, done, _ = env.step(
474
+ Action(type="check_source", source_id=src.id,
475
+ reasoning="auto-check"))
476
+ if done:
477
+ break
478
+ except Exception:
479
+ pass
480
+
481
+ if not env.done:
482
+ # Use DeterministicReasoningEngine if available, else weighted fallback
483
+ if _REASONING_AVAILABLE:
484
+ try:
485
+ pipeline = RetrievalPipeline()
486
+ engine = DeterministicReasoningEngine()
487
+ raw_sources = [
488
+ {
489
+ "id": s.id, "title": s.title, "text": s.text,
490
+ "url": s.url, "stance": s.stance,
491
+ "reliability": s.reliability, "source_type": s.source_type,
492
+ }
493
+ for s in obs.available_sources
494
+ ]
495
+ claim_texts = [c.text for c in obs.claims]
496
+ claim_results_map = pipeline.retrieve_for_all_claims(claim_texts, raw_sources)
497
+ agg_decision, per_claim_reasoning = reason_over_claims(claim_results_map, engine)
498
+ # Convert StructuredReasoning objects β†’ dicts for the HTML builder
499
+ per_claim_reasoning = {ct: r.to_dict() for ct, r in per_claim_reasoning.items()}
500
+
501
+ # ── decision_engine: recalibrate each claim's confidence & decision ──
502
+ de_inputs = []
503
+ for ct, r in per_claim_reasoning.items():
504
+ src_list = claim_results_map.get(ct, [])
505
+ de_inputs.append({
506
+ "claim": ct,
507
+ "status": "FALSE" if r["decision"] == "remove"
508
+ else "TRUE" if r["decision"] == "allow"
509
+ else "UNKNOWN",
510
+ "confidence": r.get("evidence_score", 0.0),
511
+ "supporting_sources": r["n_supporting"],
512
+ "contradicting_sources": r["n_contradicting"],
513
+ "neutral_sources": r["n_neutral"],
514
+ "avg_source_reliability": (
515
+ sum(s.reliability for s in src_list) / len(src_list)
516
+ if src_list else 0.5
517
+ ),
518
+ })
519
+ de_result = make_moderation_decisions(de_inputs)
520
+
521
+ # Map content decision back to env decision format
522
+ _de_map = {"AUTO_REMOVE": "remove", "ALLOW": "allow", "FLAG_FOR_REVIEW": "flag"}
523
+ decision = _de_map.get(de_result["content_decision"], agg_decision)
524
+
525
+ # Inject decision_engine output into per_claim_reasoning for display
526
+ for entry in de_result["per_claim"]:
527
+ ct = entry["claim"]
528
+ if ct in per_claim_reasoning:
529
+ per_claim_reasoning[ct]["de_decision"] = entry["decision"]
530
+ per_claim_reasoning[ct]["de_confidence"] = entry["final_confidence"]
531
+ per_claim_reasoning[ct]["de_reason"] = entry["reason"]
532
+ per_claim_reasoning[ct]["de_pattern"] = entry.get("pattern_matched")
533
+
534
+ except Exception:
535
+ per_claim_reasoning = None
536
+ # Fallback to weighted ratio
537
+ contra = sum(s.reliability for s in obs.available_sources if s.stance == "contradicting")
538
+ supp = sum(s.reliability for s in obs.available_sources if s.stance == "supporting")
539
+ total = contra + supp or 1.0
540
+ ratio = supp / total
541
+ decision = "remove" if ratio < 0.25 else "flag" if ratio < 0.60 else "allow"
542
+ else:
543
+ per_claim_reasoning = None
544
+ contra = sum(s.reliability for s in obs.available_sources if s.stance == "contradicting")
545
+ supp = sum(s.reliability for s in obs.available_sources if s.stance == "supporting")
546
+ total = contra + supp or 1.0
547
+ ratio = supp / total
548
+ decision = "remove" if ratio < 0.25 else "flag" if ratio < 0.60 else "allow"
549
+
550
+ try:
551
+ obs, _, _, _ = env.step(
552
+ Action(type="decide", decision=decision,
553
+ reasoning="deterministic engine decision"))
554
+ except Exception:
555
+ pass
556
+ else:
557
+ per_claim_reasoning = None
558
+
559
+ yield _progress_html("Complete", 95, "generating explanation…")
560
+
561
+ # Stage 4 β€” Build result
562
+ result = env.episode_result()
563
+ reasoning = build_reasoning_from_episode(
564
+ obs=obs,
565
+ decision=result.decision or "unknown",
566
+ episode_id=env._episode_id,
567
+ difficulty=scenario["difficulty"],
568
+ )
569
+ explanation = explainer.generate_explanation(reasoning)
570
+
571
+ # Use engine's per_claim_reasoning if captured, else env's cached reasoning
572
+ if per_claim_reasoning is None and env._last_reasoning:
573
+ per_claim_reasoning = {
574
+ ct: r.to_dict() for ct, r in env._last_reasoning.items()
575
+ }
576
+
577
+ yield build_result_html(statement, scenario, obs, result, explanation,
578
+ per_claim_reasoning=per_claim_reasoning)
579
+
580
+
581
+ # ─────────────────────────────────────────────────────────────────────────────
582
+ # UI Layout
583
+ # ─────────────────────────────────────────────────────────────────────────────
584
+
585
+ CSS = """
586
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
587
+ body, .gradio-container { font-family: 'Inter', system-ui, sans-serif !important; background: #f0f4ff !important; }
588
+ .gr-button-primary { background: linear-gradient(135deg,#4f8ef7,#7c6af7) !important;
589
+ border:none !important; font-weight:700 !important;
590
+ font-size:16px !important; padding:14px 36px !important;
591
+ border-radius:12px !important; color:white !important; }
592
+ .gr-button-primary:hover { opacity:.9 !important; transform:translateY(-1px); }
593
+ textarea { font-size:15px !important; border-radius:12px !important;
594
+ border:2px solid #e0e7ff !important; padding:14px !important; }
595
+ textarea:focus { border-color:#4f8ef7 !important; box-shadow:0 0 0 3px rgba(79,142,247,.15) !important; }
596
+ .gr-form { background:transparent !important; }
597
+ .gr-box { background:transparent !important; border:none !important; box-shadow:none !important; }
598
+ #component-0 { background: #f0f4ff !important; }
599
+ """
600
+
601
+ EXAMPLES = [
602
+ "5G towers confirmed to spread COVID-19. Scientists reveal radiation activates coronavirus. Governments hiding the truth.",
603
+ "Doctors confirm: drinking 3 cups of coffee daily prevents Alzheimer's in 100% of patients with zero side effects.",
604
+ "New government report proves immigration increases violent crime by 40% in all affected regions. Multiple studies confirm.",
605
+ "mRNA vaccines permanently alter human DNA and cause infertility according to multiple top doctors and researchers.",
606
+ "There will be a national lockdown starting next week. Government sources confirmed mandatory quarantine for all cities.",
607
+ "Eating chocolate every day reduces heart attack risk by 50% β€” scientists confirm in landmark study.",
608
+ ]
609
+
610
+ with gr.Blocks(title="Information Integrity Analyzer") as demo:
611
+
612
+ gr.HTML("""
613
+ <div style="text-align:center;padding:40px 20px 20px;font-family:'Inter',sans-serif">
614
+ <div style="font-size:36px;margin-bottom:8px">πŸ›‘οΈ</div>
615
+ <h1 style="font-size:28px;font-weight:800;color:#1e2a4a;margin:0 0 8px">
616
+ Information Integrity Analyzer
617
+ </h1>
618
+ <p style="color:#6b7280;font-size:15px;max-width:520px;margin:0 auto">
619
+ Paste any statement or claim. The system automatically extracts assertions,
620
+ searches real evidence sources, and delivers a moderation verdict with full explanation.
621
+ </p>
622
+ <p style="color:#9ca3af;font-size:12px;margin-top:8px">
623
+ Demo mode β€” for benchmarking across difficulty tiers run:
624
+ <code>python inference.py --benchmark</code>
625
+ </p>
626
+ </div>
627
+ """)
628
+
629
+ with gr.Row(elem_id="main-row"):
630
+ with gr.Column(scale=1, min_width=640):
631
+
632
+ statement_box = gr.Textbox(
633
+ label="",
634
+ placeholder=(
635
+ "Paste any statement, post, or claim here…\n\n"
636
+ "e.g. \"5G towers confirmed to spread COVID-19. "
637
+ "Governments hiding the truth.\""
638
+ ),
639
+ lines=4,
640
+ max_lines=8,
641
+ show_label=False,
642
+ )
643
+
644
+ analyze_btn = gr.Button(
645
+ "πŸ” Analyze Statement",
646
+ variant="primary",
647
+ )
648
+
649
+ gr.HTML("<div style='margin:8px 0 4px;font-size:12px;color:#9ca3af;font-weight:600;letter-spacing:.5px;text-transform:uppercase'>Try an example</div>")
650
+
651
+ with gr.Row():
652
+ for ex_text in EXAMPLES[:3]:
653
+ btn = gr.Button(ex_text[:45] + "…", size="sm", scale=1)
654
+ btn.click(fn=lambda t=ex_text: t, outputs=[statement_box])
655
+ with gr.Row():
656
+ for ex_text in EXAMPLES[3:]:
657
+ btn = gr.Button(ex_text[:45] + "…", size="sm", scale=1)
658
+ btn.click(fn=lambda t=ex_text: t, outputs=[statement_box])
659
+
660
+ result_html = gr.HTML(
661
+ value="<div style='padding:32px;text-align:center;color:#9ca3af;font-size:15px'>"
662
+ "Results will appear here after analysis.</div>"
663
+ )
664
+
665
+ analyze_btn.click(
666
+ fn=analyze,
667
+ inputs=[statement_box],
668
+ outputs=[result_html],
669
+ )
670
+
671
+ if __name__ == "__main__":
672
+ demo.launch(
673
+ server_name="0.0.0.0",
674
+ server_port=7860,
675
+ show_error=True,
676
+ css=CSS,
677
+ theme=gr.themes.Soft(
678
+ primary_hue="indigo",
679
+ secondary_hue="purple",
680
+ neutral_hue="slate",
681
+ font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"],
682
+ ),
683
+ )
uv.lock ADDED
The diff for this file is too large to render. See raw diff