Spaces:
Sleeping
Sleeping
UTKARSH MISHRA commited on
Commit Β·
e5e36db
0
Parent(s):
Initial commit: InfoClassifier project
Browse files- .env.example +34 -0
- .gitattributes +35 -0
- .gitignore +29 -0
- Dockerfile +90 -0
- README.md +359 -0
- app.py +41 -0
- data_collector.py +1229 -0
- decision_engine.py +454 -0
- docker-compose.yml +31 -0
- domain_reliability.yaml +357 -0
- enhanced_stance_classifier.py +798 -0
- environment.py +631 -0
- explainability.py +445 -0
- inference.py +713 -0
- models.py +277 -0
- openenv.yaml +270 -0
- pyproject.toml +32 -0
- quality_weighted_aggregator.py +433 -0
- reasoning.py +862 -0
- requirements.txt +30 -0
- retrieval.py +935 -0
- reward.py +297 -0
- reward_config.yaml +31 -0
- run_evaluation.py +221 -0
- server.py +574 -0
- server/__init__.py +22 -0
- server/app.py +47 -0
- stance_classifier.py +610 -0
- tasks/__init__.py +5 -0
- tasks/easy/scenarios.json +313 -0
- tasks/graders.py +305 -0
- tasks/hard/scenarios.json +481 -0
- tasks/medium/scenarios.json +387 -0
- tasks/registry.py +156 -0
- test_enhanced_stance_system.py +478 -0
- ui.py +683 -0
- uv.lock +0 -0
.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}β¦ <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("<", "<").replace(">", ">")
|
| 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> Β·
|
| 334 |
+
Steps: <b>{result.steps_taken}</b> Β·
|
| 335 |
+
Reward: <b>{result.total_reward:+.2f}</b> Β·
|
| 336 |
+
Grader: <b>{result.grader_score:.0%}</b>
|
| 337 |
+
{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
|
|
|