Spaces:
Runtime error
Runtime error
InfoShield backend (Docker)
Browse filesCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .dockerignore +17 -0
- .gitattributes +1 -0
- Dockerfile +24 -0
- README.md +6 -7
- backend/analyzer.py +275 -0
- backend/app.py +313 -0
- backend/cache.py +143 -0
- backend/classifier.py +187 -0
- backend/config.py +299 -0
- backend/data/cache.json +795 -0
- backend/data/posts.json +385 -0
- backend/data/video.mp4 +3 -0
- backend/llm.py +155 -0
- backend/manip_classifier.py +156 -0
- backend/pipeline.py +71 -0
- backend/rebuild.py +58 -0
- backend/requirements.txt +20 -0
- backend/scripts/build_cache.py +41 -0
- backend/scripts/download_models.py +41 -0
- backend/scripts/evaluate.py +159 -0
- backend/scripts/finetune.py +84 -0
- backend/scripts/seed_demo.py +193 -0
.dockerignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the Docker build context (and the HF Space) lean: only backend/ is needed.
|
| 2 |
+
.git/
|
| 3 |
+
.venv/
|
| 4 |
+
**/__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.log
|
| 7 |
+
.env
|
| 8 |
+
|
| 9 |
+
node_modules/
|
| 10 |
+
frontend/
|
| 11 |
+
extension/
|
| 12 |
+
assets/
|
| 13 |
+
docs/
|
| 14 |
+
tools/
|
| 15 |
+
|
| 16 |
+
# Local ML models are re-downloaded on first boot, never shipped.
|
| 17 |
+
backend/models/
|
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* 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
|
|
|
|
|
|
| 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
|
| 36 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# InfoShield backend for Hugging Face Spaces (Docker SDK).
|
| 2 |
+
# Serves the prebuilt cache instantly; downloads the local ML models on first
|
| 3 |
+
# boot for the live "Check with InfoShield" draft analysis. Gemini key comes
|
| 4 |
+
# from the Space secret GEMINI_API_KEY (never baked into the image).
|
| 5 |
+
FROM python:3.11-slim
|
| 6 |
+
|
| 7 |
+
# Non-root user (HF Spaces best practice) with writable caches under $HOME.
|
| 8 |
+
RUN useradd -m -u 1000 user
|
| 9 |
+
ENV HOME=/home/user \
|
| 10 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 11 |
+
INFOSHIELD_HOST=0.0.0.0 \
|
| 12 |
+
INFOSHIELD_PORT=7860 \
|
| 13 |
+
PYTHONUNBUFFERED=1
|
| 14 |
+
|
| 15 |
+
WORKDIR /home/user/app
|
| 16 |
+
|
| 17 |
+
COPY --chown=user backend/requirements.txt ./requirements.txt
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY --chown=user backend/ ./
|
| 21 |
+
|
| 22 |
+
USER user
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
---
|
| 2 |
title: Infoshield Backend
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Infoshield Backend
|
| 3 |
+
emoji: 🛡️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
InfoShield backend (Flask + Docker): serves the cached feed and the live
|
| 12 |
+
draft analysis. The Gemini key is provided via the Space secret GEMINI_API_KEY.
|
backend/analyzer.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Educational analysis (the explainer) + verifier (the judge).
|
| 2 |
+
|
| 3 |
+
The local models (hate + manipulation) decide WHETHER a post is flagged. This
|
| 4 |
+
module decides WHY:
|
| 5 |
+
* analyze(text, kind) -- runs the hate OR manipulation analysis prompt and
|
| 6 |
+
returns techniques / severity / explanation / highlights.
|
| 7 |
+
* verify(text, analysis, kind) -- a second LLM pass that judges whether the
|
| 8 |
+
analysis is accurate/relevant; if not, the caller
|
| 9 |
+
overturns the flag (false positive).
|
| 10 |
+
|
| 11 |
+
Provider-agnostic (llm.py): Groq now, Gemini later. Privacy: only post text is
|
| 12 |
+
sent. If the LLM is unavailable, analyze() degrades to a local heuristic and
|
| 13 |
+
verify() fails open (keeps the original decision).
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from config import (
|
| 22 |
+
ALLOWED_TECHNIQUES,
|
| 23 |
+
ANALYSIS_SYSTEM_INSTRUCTION,
|
| 24 |
+
HATE_ANALYSIS_PROMPT,
|
| 25 |
+
LEGAL_SOURCE_URLS,
|
| 26 |
+
MANIPULATION_ANALYSIS_PROMPT,
|
| 27 |
+
MAX_EXPLANATION_CHARS,
|
| 28 |
+
REPHRASE_PROMPT,
|
| 29 |
+
REPHRASE_SYSTEM_INSTRUCTION,
|
| 30 |
+
TECHNIQUE_LABELS,
|
| 31 |
+
VERIFIER_MODEL,
|
| 32 |
+
VERIFIER_PROMPT,
|
| 33 |
+
VERIFIER_PROVIDER,
|
| 34 |
+
VERIFIER_SYSTEM_INSTRUCTION,
|
| 35 |
+
)
|
| 36 |
+
from llm import available, complete_json # noqa: F401 (available re-exported)
|
| 37 |
+
|
| 38 |
+
log = logging.getLogger("infoshield.analyzer")
|
| 39 |
+
|
| 40 |
+
ANALYSIS_SCHEMA = {
|
| 41 |
+
"type": "object",
|
| 42 |
+
"properties": {
|
| 43 |
+
"techniques": {
|
| 44 |
+
"type": "array",
|
| 45 |
+
"items": {"type": "string", "enum": ALLOWED_TECHNIQUES},
|
| 46 |
+
},
|
| 47 |
+
"emotional_triggers": {"type": "array", "items": {"type": "string"}},
|
| 48 |
+
"explanation": {"type": "string"},
|
| 49 |
+
"recommendation": {"type": "string"},
|
| 50 |
+
"severity": {"type": "string", "enum": ["low", "medium", "high"]},
|
| 51 |
+
"highlights": {
|
| 52 |
+
"type": "array",
|
| 53 |
+
"items": {
|
| 54 |
+
"type": "object",
|
| 55 |
+
"properties": {
|
| 56 |
+
"phrase": {"type": "string"},
|
| 57 |
+
"type": {"type": "string"},
|
| 58 |
+
"label": {"type": "string"},
|
| 59 |
+
},
|
| 60 |
+
"required": ["phrase", "type", "label"],
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
"bias_check": {"type": "string"},
|
| 64 |
+
"citations": {"type": "array", "items": {"type": "string"}},
|
| 65 |
+
},
|
| 66 |
+
"required": ["techniques", "emotional_triggers", "explanation",
|
| 67 |
+
"recommendation", "severity", "highlights", "bias_check", "citations"],
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
_DEFAULT_BIAS_CHECK = (
|
| 71 |
+
"Checked against Article 19 free-expression protections and non-discrimination "
|
| 72 |
+
"safeguards; flagged only on concrete textual evidence."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
_STRICT_SUFFIX = (
|
| 76 |
+
"\n\nIMPORTANT: Output MUST be a single valid JSON object matching the schema "
|
| 77 |
+
"exactly. No markdown fences, no commentary."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# --- validation -----------------------------------------------------------
|
| 82 |
+
def validate_analysis(raw: dict, text: str) -> Optional[dict]:
|
| 83 |
+
"""Enforce allowlist, exact-substring highlights, no overlaps, length caps.
|
| 84 |
+
Returns None when there is nothing usable (triggers retry / fallback)."""
|
| 85 |
+
if not isinstance(raw, dict):
|
| 86 |
+
return None
|
| 87 |
+
lower = text.lower()
|
| 88 |
+
|
| 89 |
+
techniques = [t for t in raw.get("techniques", []) if t in ALLOWED_TECHNIQUES]
|
| 90 |
+
techniques = list(dict.fromkeys(techniques))
|
| 91 |
+
|
| 92 |
+
triggers = [str(t).strip() for t in raw.get("emotional_triggers", []) if str(t).strip()]
|
| 93 |
+
triggers = list(dict.fromkeys(triggers))[:6]
|
| 94 |
+
|
| 95 |
+
explanation = str(raw.get("explanation", "")).strip()[:MAX_EXPLANATION_CHARS]
|
| 96 |
+
recommendation = str(raw.get("recommendation", "")).strip()[:200]
|
| 97 |
+
|
| 98 |
+
severity = raw.get("severity")
|
| 99 |
+
if severity not in ("low", "medium", "high"):
|
| 100 |
+
severity = "medium"
|
| 101 |
+
|
| 102 |
+
highlights = []
|
| 103 |
+
used_spans: list[tuple[int, int]] = []
|
| 104 |
+
for h in raw.get("highlights", []):
|
| 105 |
+
if not isinstance(h, dict):
|
| 106 |
+
continue
|
| 107 |
+
phrase = str(h.get("phrase") or h.get("text") or h.get("quote") or "").strip()
|
| 108 |
+
if not phrase:
|
| 109 |
+
continue
|
| 110 |
+
idx = lower.find(phrase.lower())
|
| 111 |
+
if idx == -1:
|
| 112 |
+
continue
|
| 113 |
+
span = (idx, idx + len(phrase))
|
| 114 |
+
if any(span[0] < e and s < span[1] for s, e in used_spans):
|
| 115 |
+
continue
|
| 116 |
+
used_spans.append(span)
|
| 117 |
+
htype = str(h.get("type", "")).strip() or "emotional_manipulation"
|
| 118 |
+
label = str(h.get("label", "")).strip() or TECHNIQUE_LABELS.get(
|
| 119 |
+
htype, htype.replace("_", " ").title()
|
| 120 |
+
)
|
| 121 |
+
highlights.append({"phrase": text[idx: idx + len(phrase)], "type": htype, "label": label})
|
| 122 |
+
|
| 123 |
+
bias_check = str(raw.get("bias_check", "")).strip()[:300] or _DEFAULT_BIAS_CHECK
|
| 124 |
+
allow = set(LEGAL_SOURCE_URLS)
|
| 125 |
+
citations = [u for u in raw.get("citations", []) if isinstance(u, str) and u in allow]
|
| 126 |
+
citations = list(dict.fromkeys(citations))[:5]
|
| 127 |
+
|
| 128 |
+
if not techniques and not highlights and not explanation:
|
| 129 |
+
return None
|
| 130 |
+
return {
|
| 131 |
+
"techniques": techniques,
|
| 132 |
+
"emotional_triggers": triggers,
|
| 133 |
+
"explanation": explanation,
|
| 134 |
+
"recommendation": recommendation,
|
| 135 |
+
"severity": severity,
|
| 136 |
+
"highlights": highlights,
|
| 137 |
+
"bias_check": bias_check,
|
| 138 |
+
"citations": citations,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# --- local fallback -------------------------------------------------------
|
| 143 |
+
_HEURISTICS = {
|
| 144 |
+
"false_urgency": ["before it", "act now", "midnight", "by monday", "running out", "last chance", "don't wait", "act before"],
|
| 145 |
+
"fear_amplification": ["destroy", "lose everything", "collapse", "disaster", "danger", "terrify", "catastroph", "doom", "ruin everything"],
|
| 146 |
+
"us_vs_them": ["us against them", "those people", "our side", "their side", "wake up", "elites", "they don't want you"],
|
| 147 |
+
"scapegoating": ["blame", "fault", "because of them", "the reason", "responsible for"],
|
| 148 |
+
"dehumanizing_language": ["vermin", "animals", "infest", "parasite", "swarm", "subhuman", "filth"],
|
| 149 |
+
"ad_hominem": ["idiot", "fool", "stupid", "liar", "sheep", "brainless"],
|
| 150 |
+
"emotional_manipulation": ["share this", "they don't want you", "hiding the truth", "stay angry", "open your eyes", "screenshot this", "real parent"],
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
_TRIGGER_WORDS = {
|
| 154 |
+
"fear": ["destroy", "lose", "danger", "afraid", "collapse", "forever"],
|
| 155 |
+
"anger": ["elites", "blame", "liar", "angry"],
|
| 156 |
+
"urgency": ["now", "before", "midnight", "monday", "immediately"],
|
| 157 |
+
"belonging": ["us", "our values", "who we are", "community"],
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def local_fallback_analysis(text: str) -> dict:
|
| 162 |
+
"""Deterministic heuristic analysis used when the LLM is unavailable."""
|
| 163 |
+
lower = text.lower()
|
| 164 |
+
techniques, highlights, used = [], [], []
|
| 165 |
+
for tech, cues in _HEURISTICS.items():
|
| 166 |
+
for cue in cues:
|
| 167 |
+
idx = lower.find(cue)
|
| 168 |
+
if idx == -1:
|
| 169 |
+
continue
|
| 170 |
+
if tech not in techniques:
|
| 171 |
+
techniques.append(tech)
|
| 172 |
+
span = (idx, idx + len(cue))
|
| 173 |
+
if not any(span[0] < e and s < span[1] for s, e in used):
|
| 174 |
+
used.append(span)
|
| 175 |
+
highlights.append({
|
| 176 |
+
"phrase": text[idx: idx + len(cue)],
|
| 177 |
+
"type": tech,
|
| 178 |
+
"label": TECHNIQUE_LABELS.get(tech, tech.replace("_", " ").title()),
|
| 179 |
+
})
|
| 180 |
+
break
|
| 181 |
+
n = len(techniques)
|
| 182 |
+
triggers = [name for name, words in _TRIGGER_WORDS.items() if any(w in lower for w in words)]
|
| 183 |
+
severity = "high" if n >= 3 else "medium" if n == 2 else "low"
|
| 184 |
+
return {
|
| 185 |
+
"techniques": techniques[:4] or ["emotional_manipulation"],
|
| 186 |
+
"emotional_triggers": triggers[:4] or ["emotional reaction"],
|
| 187 |
+
"explanation": (
|
| 188 |
+
"This post leans on emotionally charged language and framing that "
|
| 189 |
+
"pushes a strong reaction rather than inviting reflection."
|
| 190 |
+
),
|
| 191 |
+
"recommendation": "Take a moment before sharing and check whether the claim holds up calmly.",
|
| 192 |
+
"severity": severity,
|
| 193 |
+
"highlights": highlights[:6],
|
| 194 |
+
"bias_check": "Heuristic check (LLM unavailable); flags only explicit manipulative cues, free expression preserved.",
|
| 195 |
+
"citations": [],
|
| 196 |
+
"source": "local_fallback",
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _empty_analysis() -> dict:
|
| 201 |
+
return {
|
| 202 |
+
"techniques": [], "emotional_triggers": [], "explanation": "",
|
| 203 |
+
"recommendation": "", "severity": "low", "highlights": [],
|
| 204 |
+
"bias_check": _DEFAULT_BIAS_CHECK, "citations": [], "source": "empty",
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# --- analysis (the explainer) ---------------------------------------------
|
| 209 |
+
def analyze(text: str, kind: str = "manipulation") -> dict:
|
| 210 |
+
"""Run the hate OR manipulation analysis prompt; never raises for normal input."""
|
| 211 |
+
if not text or not text.strip():
|
| 212 |
+
return _empty_analysis()
|
| 213 |
+
template = HATE_ANALYSIS_PROMPT if kind == "hate" else MANIPULATION_ANALYSIS_PROMPT
|
| 214 |
+
|
| 215 |
+
raw = complete_json(ANALYSIS_SYSTEM_INSTRUCTION, template.format(text=text), schema=ANALYSIS_SCHEMA)
|
| 216 |
+
analysis = validate_analysis(raw, text) if raw else None
|
| 217 |
+
if analysis is None:
|
| 218 |
+
raw = complete_json(
|
| 219 |
+
ANALYSIS_SYSTEM_INSTRUCTION, template.format(text=text) + _STRICT_SUFFIX, schema=ANALYSIS_SCHEMA
|
| 220 |
+
)
|
| 221 |
+
analysis = validate_analysis(raw, text) if raw else None
|
| 222 |
+
if analysis is None:
|
| 223 |
+
analysis = local_fallback_analysis(text)
|
| 224 |
+
else:
|
| 225 |
+
analysis["source"] = "llm"
|
| 226 |
+
analysis["kind"] = kind
|
| 227 |
+
return analysis
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# --- verifier (the judge) -------------------------------------------------
|
| 231 |
+
def verify(text: str, analysis: dict, kind: str) -> dict:
|
| 232 |
+
"""Second-pass LLM judge. Returns {relevant, reason, checked}.
|
| 233 |
+
|
| 234 |
+
Fails OPEN: if the verifier can't run, relevant=True (keep the flag).
|
| 235 |
+
"""
|
| 236 |
+
payload = json.dumps(
|
| 237 |
+
{k: analysis.get(k) for k in ("techniques", "explanation", "severity", "bias_check", "citations")},
|
| 238 |
+
ensure_ascii=False,
|
| 239 |
+
)
|
| 240 |
+
# The configured verifier model id is Gemini-specific; only pass it when the
|
| 241 |
+
# verifier provider is Gemini (otherwise use the provider's default model).
|
| 242 |
+
vmodel = VERIFIER_MODEL if VERIFIER_PROVIDER == "gemini" else None
|
| 243 |
+
raw = complete_json(
|
| 244 |
+
VERIFIER_SYSTEM_INSTRUCTION,
|
| 245 |
+
VERIFIER_PROMPT.format(kind=kind, text=text, analysis=payload),
|
| 246 |
+
provider=VERIFIER_PROVIDER,
|
| 247 |
+
model=vmodel or None,
|
| 248 |
+
temperature=0.0, # deterministic verdict
|
| 249 |
+
)
|
| 250 |
+
if not isinstance(raw, dict) or "relevant" not in raw:
|
| 251 |
+
return {"relevant": True, "reason": "verifier unavailable", "checked": False}
|
| 252 |
+
return {
|
| 253 |
+
"relevant": bool(raw.get("relevant")),
|
| 254 |
+
"reason": str(raw.get("reason", "")).strip()[:200],
|
| 255 |
+
"checked": True,
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# --- rephrase (live compose-assist) ---------------------------------------
|
| 260 |
+
def rephrase(text: str) -> str:
|
| 261 |
+
"""Rewrite a draft to remove manipulation/hate while keeping the author's
|
| 262 |
+
viewpoint. Returns the original text on failure (never raises, never censors)."""
|
| 263 |
+
if not text or not text.strip():
|
| 264 |
+
return text
|
| 265 |
+
raw = complete_json(REPHRASE_SYSTEM_INSTRUCTION, REPHRASE_PROMPT.format(text=text))
|
| 266 |
+
if isinstance(raw, dict):
|
| 267 |
+
out = str(raw.get("rephrased") or "").strip()
|
| 268 |
+
if out:
|
| 269 |
+
return out
|
| 270 |
+
return text
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# Backwards-compatible alias for the /analyze endpoint.
|
| 274 |
+
def detect_and_analyze(text: str) -> dict:
|
| 275 |
+
return analyze(text, "manipulation")
|
backend/app.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""InfoShield Flask backend.
|
| 2 |
+
|
| 3 |
+
Cache-first by design: on startup it serves a pre-built JSON cache so the demo
|
| 4 |
+
is instant and works fully offline. Heavy ML / Gemini code is imported lazily,
|
| 5 |
+
so this process starts with only Flask installed when serving from cache.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import threading
|
| 11 |
+
from collections import Counter
|
| 12 |
+
from urllib.parse import urlparse
|
| 13 |
+
|
| 14 |
+
from flask import Flask, jsonify, request, send_from_directory
|
| 15 |
+
from flask_cors import CORS
|
| 16 |
+
|
| 17 |
+
import config
|
| 18 |
+
from cache import (
|
| 19 |
+
get_cache,
|
| 20 |
+
load_activation,
|
| 21 |
+
load_posts,
|
| 22 |
+
reset_activation,
|
| 23 |
+
save_activation,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
logging.basicConfig(
|
| 27 |
+
level=logging.INFO,
|
| 28 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 29 |
+
datefmt="%H:%M:%S",
|
| 30 |
+
)
|
| 31 |
+
log = logging.getLogger("infoshield.app")
|
| 32 |
+
|
| 33 |
+
app = Flask(__name__)
|
| 34 |
+
CORS(app) # localhost demo: allow the Vite frontend and the extension to call us.
|
| 35 |
+
|
| 36 |
+
POSTS = load_posts()
|
| 37 |
+
POSTS_BY_ID = {p["id"]: p for p in POSTS}
|
| 38 |
+
CACHE = get_cache()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# --- helpers --------------------------------------------------------------
|
| 42 |
+
def _merged_post(post: dict) -> dict:
|
| 43 |
+
"""Serve cached classification + analysis; compute, store, and persist on a miss
|
| 44 |
+
so activating the extension shows saved results instantly."""
|
| 45 |
+
cached = CACHE.get(post["id"])
|
| 46 |
+
if not cached or not cached.get("classification"):
|
| 47 |
+
from pipeline import analyze_post
|
| 48 |
+
|
| 49 |
+
classification, analysis = analyze_post(post["text"])
|
| 50 |
+
CACHE.set(post["id"], classification, analysis)
|
| 51 |
+
CACHE.save()
|
| 52 |
+
cached = {"classification": classification, "analysis": analysis}
|
| 53 |
+
cls = cached.get("classification") or {}
|
| 54 |
+
return {
|
| 55 |
+
**post,
|
| 56 |
+
"classification": cls,
|
| 57 |
+
"analysis": cached.get("analysis"),
|
| 58 |
+
"flagged": bool(cls.get("flagged")),
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _warm_cache() -> None:
|
| 63 |
+
"""Background pre-warm: compute analysis for any uncached posts so the demo is
|
| 64 |
+
instant by the time the user activates the extension."""
|
| 65 |
+
try:
|
| 66 |
+
from classifier import get_classifier
|
| 67 |
+
from manip_classifier import get_manip_classifier
|
| 68 |
+
|
| 69 |
+
get_classifier().load()
|
| 70 |
+
get_manip_classifier().load()
|
| 71 |
+
missing = [p for p in POSTS if not (CACHE.get(p["id"]) or {}).get("classification")]
|
| 72 |
+
if not missing:
|
| 73 |
+
log.info("Cache already warm (%d posts).", len(POSTS))
|
| 74 |
+
return
|
| 75 |
+
log.info("Warming cache for %d post(s) in background ...", len(missing))
|
| 76 |
+
for p in missing:
|
| 77 |
+
_merged_post(p)
|
| 78 |
+
log.info("Cache warm complete.")
|
| 79 |
+
except Exception as exc: # noqa: BLE001
|
| 80 |
+
log.warning("Cache warm failed: %s", exc)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _is_localhost(url: str) -> bool:
|
| 84 |
+
try:
|
| 85 |
+
host = (urlparse(url).hostname or "").lower()
|
| 86 |
+
except Exception: # noqa: BLE001
|
| 87 |
+
return False
|
| 88 |
+
return host in ("localhost", "127.0.0.1", "0.0.0.0", "::1")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# --- routes ---------------------------------------------------------------
|
| 92 |
+
@app.get("/health")
|
| 93 |
+
def health():
|
| 94 |
+
from classifier import get_classifier
|
| 95 |
+
|
| 96 |
+
from llm import available as llm_available
|
| 97 |
+
|
| 98 |
+
clf = get_classifier()
|
| 99 |
+
cached = sum(1 for p in POSTS if (CACHE.get(p["id"]) or {}).get("classification"))
|
| 100 |
+
return jsonify({
|
| 101 |
+
"status": "ok",
|
| 102 |
+
"service": "infoshield",
|
| 103 |
+
"mode": "cached",
|
| 104 |
+
"hate_model": config.PRIMARY_MODEL,
|
| 105 |
+
"manipulation_model": config.MANIP_MODEL,
|
| 106 |
+
"model_loaded": clf.loaded,
|
| 107 |
+
"device": clf.device if clf.loaded else "unknown",
|
| 108 |
+
"posts": len(POSTS),
|
| 109 |
+
"cached_posts": cached,
|
| 110 |
+
"llm_provider": config.LLM_PROVIDER,
|
| 111 |
+
"verifier_model": config.VERIFIER_MODEL,
|
| 112 |
+
"llm_configured": llm_available(),
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.get("/feed")
|
| 117 |
+
def feed():
|
| 118 |
+
return jsonify({"posts": [_merged_post(p) for p in POSTS]})
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@app.get("/media/<path:filename>")
|
| 122 |
+
def media(filename: str):
|
| 123 |
+
"""Serve demo media (e.g. the feed video) from backend/data."""
|
| 124 |
+
return send_from_directory(config.DATA_DIR, filename)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@app.get("/post-analysis/<post_id>")
|
| 128 |
+
def post_analysis(post_id: str):
|
| 129 |
+
post = POSTS_BY_ID.get(post_id)
|
| 130 |
+
if post is None:
|
| 131 |
+
return jsonify({"error": "unknown post id"}), 404
|
| 132 |
+
merged = _merged_post(post)
|
| 133 |
+
return jsonify({
|
| 134 |
+
"id": post_id,
|
| 135 |
+
"text": post["text"],
|
| 136 |
+
"classification": merged["classification"],
|
| 137 |
+
"analysis": merged["analysis"],
|
| 138 |
+
"flagged": merged["flagged"],
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@app.post("/rebuild-cache")
|
| 143 |
+
def rebuild_cache_endpoint():
|
| 144 |
+
"""Trigger a full live rebuild (local classifier + Gemini) and overwrite the
|
| 145 |
+
cache. This is the ONLY endpoint that incurs API cost. Results are live
|
| 146 |
+
immediately -- it updates the in-memory cache, no restart needed."""
|
| 147 |
+
from rebuild import rebuild_cache
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
summary = rebuild_cache(CACHE, POSTS)
|
| 151 |
+
except Exception as exc: # noqa: BLE001
|
| 152 |
+
log.error("rebuild failed: %s", exc)
|
| 153 |
+
return jsonify({"status": "error", "error": str(exc)}), 503
|
| 154 |
+
return jsonify({"status": "ok", **summary})
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.post("/classify")
|
| 158 |
+
def classify():
|
| 159 |
+
"""Hate-only classification of arbitrary text (live, content-only)."""
|
| 160 |
+
data = request.get_json(silent=True) or {}
|
| 161 |
+
text = (data.get("text") or "").strip()
|
| 162 |
+
from classifier import get_classifier
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
return jsonify(get_classifier().classify(text))
|
| 166 |
+
except Exception as exc: # noqa: BLE001
|
| 167 |
+
log.error("classify failed: %s", exc)
|
| 168 |
+
return jsonify({"error": "classifier unavailable", "detail": str(exc)}), 503
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.post("/analyze")
|
| 172 |
+
def analyze():
|
| 173 |
+
"""Manipulation detection + educational analysis of arbitrary text (live)."""
|
| 174 |
+
data = request.get_json(silent=True) or {}
|
| 175 |
+
text = (data.get("text") or "").strip()
|
| 176 |
+
if not text:
|
| 177 |
+
return jsonify({"error": "empty text"}), 400
|
| 178 |
+
from analyzer import detect_and_analyze
|
| 179 |
+
|
| 180 |
+
return jsonify(detect_and_analyze(text))
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.post("/analyze-text")
|
| 184 |
+
def analyze_text():
|
| 185 |
+
"""Full pipeline (both detectors + analysis + verifier) on a user's own draft.
|
| 186 |
+
Always LIVE -- never cached -- so the compose-assist demo runs fresh each time."""
|
| 187 |
+
data = request.get_json(silent=True) or {}
|
| 188 |
+
text = (data.get("text") or "").strip()
|
| 189 |
+
if not text:
|
| 190 |
+
return jsonify({"error": "empty text"}), 400
|
| 191 |
+
from pipeline import analyze_post
|
| 192 |
+
|
| 193 |
+
classification, analysis = analyze_post(text)
|
| 194 |
+
return jsonify({
|
| 195 |
+
"text": text,
|
| 196 |
+
"classification": classification,
|
| 197 |
+
"analysis": analysis,
|
| 198 |
+
"flagged": classification["flagged"],
|
| 199 |
+
})
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@app.post("/rephrase")
|
| 203 |
+
def rephrase_endpoint():
|
| 204 |
+
"""Rewrite the user's draft to remove manipulation/hate while keeping their
|
| 205 |
+
viewpoint. Live; never cached; never censors (returns original on failure)."""
|
| 206 |
+
data = request.get_json(silent=True) or {}
|
| 207 |
+
text = (data.get("text") or "").strip()
|
| 208 |
+
if not text:
|
| 209 |
+
return jsonify({"error": "empty text"}), 400
|
| 210 |
+
from analyzer import rephrase
|
| 211 |
+
|
| 212 |
+
return jsonify({"rephrased": rephrase(text)})
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@app.get("/stats")
|
| 216 |
+
def stats():
|
| 217 |
+
posts = [_merged_post(p) for p in POSTS]
|
| 218 |
+
flagged = [p for p in posts if p["flagged"]]
|
| 219 |
+
tech_counter: Counter = Counter()
|
| 220 |
+
confidences = []
|
| 221 |
+
high_conf = 0
|
| 222 |
+
for p in flagged:
|
| 223 |
+
cls = p["classification"] or {}
|
| 224 |
+
confidences.append(cls.get("confidence", 0.0))
|
| 225 |
+
if cls.get("high_confidence"):
|
| 226 |
+
high_conf += 1
|
| 227 |
+
for t in (p["analysis"] or {}).get("techniques", []):
|
| 228 |
+
tech_counter[t] += 1
|
| 229 |
+
|
| 230 |
+
distribution = {t: tech_counter.get(t, 0) for t in config.ALLOWED_TECHNIQUES if tech_counter.get(t)}
|
| 231 |
+
most_common = tech_counter.most_common(1)
|
| 232 |
+
most_common_technique = most_common[0][0] if most_common else None
|
| 233 |
+
most_common_label = (
|
| 234 |
+
config.TECHNIQUE_LABELS.get(most_common_technique, most_common_technique)
|
| 235 |
+
if most_common_technique else "—"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
return jsonify({
|
| 239 |
+
"totals": {
|
| 240 |
+
"posts": len(POSTS),
|
| 241 |
+
"flagged": len(flagged),
|
| 242 |
+
"benign": len(POSTS) - len(flagged),
|
| 243 |
+
},
|
| 244 |
+
"technique_distribution": distribution,
|
| 245 |
+
"most_common_technique": most_common_technique,
|
| 246 |
+
"confidences": {
|
| 247 |
+
"mean_flagged": round(sum(confidences) / len(confidences), 3) if confidences else 0,
|
| 248 |
+
"max": round(max(confidences), 3) if confidences else 0,
|
| 249 |
+
"min": round(min(confidences), 3) if confidences else 0,
|
| 250 |
+
"high_confidence_count": high_conf,
|
| 251 |
+
},
|
| 252 |
+
"model": {
|
| 253 |
+
"hate_model": config.PRIMARY_MODEL,
|
| 254 |
+
"manipulation_model": config.MANIP_MODEL,
|
| 255 |
+
"llm_provider": config.LLM_PROVIDER,
|
| 256 |
+
"analyzer_model": config.GEMINI_MODEL if config.LLM_PROVIDER == "gemini" else config.GROQ_MODEL,
|
| 257 |
+
"verifier_model": config.VERIFIER_MODEL,
|
| 258 |
+
"flag_threshold": config.FLAG_THRESHOLD,
|
| 259 |
+
"manipulation_threshold": config.MANIP_THRESHOLD,
|
| 260 |
+
"high_confidence": config.HIGH_CONFIDENCE,
|
| 261 |
+
},
|
| 262 |
+
# Data-driven snapshot. The right-rail widget now renders from the
|
| 263 |
+
# extension's live page scan; this stays as an honest API view.
|
| 264 |
+
"insights": {
|
| 265 |
+
"scanned": len(POSTS),
|
| 266 |
+
"posts_analyzed": len(POSTS),
|
| 267 |
+
"patterns_detected": len(distribution),
|
| 268 |
+
"most_common_pattern": most_common_label,
|
| 269 |
+
},
|
| 270 |
+
"mode": "cached",
|
| 271 |
+
})
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@app.get("/activation-target")
|
| 275 |
+
def get_activation_target():
|
| 276 |
+
return jsonify(load_activation())
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@app.post("/activation-target")
|
| 280 |
+
def set_activation_target():
|
| 281 |
+
data = request.get_json(silent=True) or {}
|
| 282 |
+
url = (data.get("target_url") or "").strip()
|
| 283 |
+
enabled = bool(data.get("enabled", True))
|
| 284 |
+
if not url:
|
| 285 |
+
return jsonify({"error": "target_url is required"}), 400
|
| 286 |
+
if not url.startswith("http://") or not _is_localhost(url):
|
| 287 |
+
return jsonify({
|
| 288 |
+
"error": "target_url must be a localhost address "
|
| 289 |
+
"(http://localhost/... or http://127.0.0.1/...)"
|
| 290 |
+
}), 400
|
| 291 |
+
return jsonify(save_activation(url, enabled))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
@app.get("/")
|
| 295 |
+
def root():
|
| 296 |
+
return jsonify({
|
| 297 |
+
"service": "InfoShield backend",
|
| 298 |
+
"routes": [
|
| 299 |
+
"/health", "/feed", "/post-analysis/<id>", "/classify",
|
| 300 |
+
"/analyze", "/stats", "/activation-target",
|
| 301 |
+
],
|
| 302 |
+
})
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
if __name__ == "__main__":
|
| 306 |
+
if config.RESET_ACTIVATION_ON_START:
|
| 307 |
+
reset_activation()
|
| 308 |
+
log.info("Activation reset to default on startup (fresh demo).")
|
| 309 |
+
# Pre-warm the analysis cache in the background so activation is instant.
|
| 310 |
+
threading.Thread(target=_warm_cache, daemon=True).start()
|
| 311 |
+
log.info("InfoShield backend starting on http://%s:%d (provider=%s, cached)",
|
| 312 |
+
config.HOST, config.PORT, config.LLM_PROVIDER)
|
| 313 |
+
app.run(host=config.HOST, port=config.PORT, debug=False)
|
backend/cache.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON-file + in-memory caching. No database.
|
| 2 |
+
|
| 3 |
+
- `cache.json` holds per-post classification + analysis so the demo is instant
|
| 4 |
+
and works fully offline after one build.
|
| 5 |
+
- `activation.json` holds the landing-page-selected target URL and on/off state.
|
| 6 |
+
- An in-memory dict caches ad-hoc /classify and /analyze results by content hash.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import threading
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
from config import ACTIVATION_FILE, CACHE_FILE, CACHE_VERSION, DATA_DIR, POSTS_FILE
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger("infoshield.cache")
|
| 20 |
+
_lock = threading.Lock()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _now() -> str:
|
| 24 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def text_hash(text: str) -> str:
|
| 28 |
+
return hashlib.sha256(text.strip().encode("utf-8")).hexdigest()[:16]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# --- posts ----------------------------------------------------------------
|
| 32 |
+
def load_posts() -> list[dict]:
|
| 33 |
+
with open(POSTS_FILE, "r", encoding="utf-8") as f:
|
| 34 |
+
return json.load(f)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# --- post cache -----------------------------------------------------------
|
| 38 |
+
class PostCache:
|
| 39 |
+
def __init__(self) -> None:
|
| 40 |
+
self.meta: dict = {}
|
| 41 |
+
self.posts: dict[str, dict] = {}
|
| 42 |
+
self.adhoc: dict[str, dict] = {} # content-hash -> {classification, analysis}
|
| 43 |
+
|
| 44 |
+
def load(self) -> bool:
|
| 45 |
+
if not CACHE_FILE.exists():
|
| 46 |
+
log.warning("Cache file %s not found -- starting empty.", CACHE_FILE)
|
| 47 |
+
return False
|
| 48 |
+
try:
|
| 49 |
+
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
| 50 |
+
data = json.load(f)
|
| 51 |
+
self.meta = data.get("meta", {})
|
| 52 |
+
self.posts = data.get("posts", {})
|
| 53 |
+
if self.meta.get("version") != CACHE_VERSION:
|
| 54 |
+
log.info("Cache version mismatch (have %s, want %s) -- discarding stale cache.",
|
| 55 |
+
self.meta.get("version"), CACHE_VERSION)
|
| 56 |
+
self.posts = {}
|
| 57 |
+
self.meta = {}
|
| 58 |
+
return False
|
| 59 |
+
log.info("Loaded cache: %d posts (built_at=%s).",
|
| 60 |
+
len(self.posts), self.meta.get("built_at"))
|
| 61 |
+
return True
|
| 62 |
+
except Exception as exc: # noqa: BLE001
|
| 63 |
+
log.error("Failed to load cache: %s", exc)
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
def save(self) -> None:
|
| 67 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
self.meta["version"] = CACHE_VERSION
|
| 69 |
+
with _lock:
|
| 70 |
+
payload = {"meta": self.meta, "posts": self.posts}
|
| 71 |
+
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
| 72 |
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 73 |
+
log.info("Cache saved (%d posts).", len(self.posts))
|
| 74 |
+
|
| 75 |
+
def get(self, post_id: str) -> Optional[dict]:
|
| 76 |
+
return self.posts.get(post_id)
|
| 77 |
+
|
| 78 |
+
def set(self, post_id: str, classification: dict, analysis: Optional[dict]) -> None:
|
| 79 |
+
self.posts[post_id] = {"classification": classification, "analysis": analysis}
|
| 80 |
+
|
| 81 |
+
def set_meta(self, **kw) -> None:
|
| 82 |
+
self.meta.update(kw)
|
| 83 |
+
self.meta["built_at"] = _now()
|
| 84 |
+
self.meta["version"] = CACHE_VERSION
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def mode(self) -> str:
|
| 88 |
+
return "cache-first" if self.posts else "empty"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
_cache: Optional[PostCache] = None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_cache() -> PostCache:
|
| 95 |
+
global _cache
|
| 96 |
+
if _cache is None:
|
| 97 |
+
_cache = PostCache()
|
| 98 |
+
_cache.load()
|
| 99 |
+
return _cache
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# --- activation config ----------------------------------------------------
|
| 103 |
+
DEFAULT_ACTIVATION = {
|
| 104 |
+
"target_url": "",
|
| 105 |
+
"enabled": False,
|
| 106 |
+
"updated_at": None,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def load_activation() -> dict:
|
| 111 |
+
if not ACTIVATION_FILE.exists():
|
| 112 |
+
return dict(DEFAULT_ACTIVATION)
|
| 113 |
+
try:
|
| 114 |
+
with open(ACTIVATION_FILE, "r", encoding="utf-8") as f:
|
| 115 |
+
data = json.load(f)
|
| 116 |
+
return {**DEFAULT_ACTIVATION, **data}
|
| 117 |
+
except Exception: # noqa: BLE001
|
| 118 |
+
return dict(DEFAULT_ACTIVATION)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def save_activation(target_url: str, enabled: bool = True) -> dict:
|
| 122 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
cfg = {
|
| 124 |
+
"target_url": target_url,
|
| 125 |
+
"enabled": enabled,
|
| 126 |
+
"updated_at": _now(),
|
| 127 |
+
}
|
| 128 |
+
with _lock:
|
| 129 |
+
with open(ACTIVATION_FILE, "w", encoding="utf-8") as f:
|
| 130 |
+
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
| 131 |
+
log.info("Activation target saved: %s (enabled=%s)", target_url, enabled)
|
| 132 |
+
return cfg
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def reset_activation() -> dict:
|
| 136 |
+
"""Reset activation to default (disabled) so each fresh run starts at the
|
| 137 |
+
landing page's ACTIVATE button."""
|
| 138 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 139 |
+
with _lock:
|
| 140 |
+
with open(ACTIVATION_FILE, "w", encoding="utf-8") as f:
|
| 141 |
+
json.dump(dict(DEFAULT_ACTIVATION), f, ensure_ascii=False, indent=2)
|
| 142 |
+
log.info("Activation reset to default (fresh demo).")
|
| 143 |
+
return dict(DEFAULT_ACTIVATION)
|
backend/classifier.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# PRIVACY BY DESIGN -- InfoShield local classifier
|
| 3 |
+
# -----------------------------------------------------------------------------
|
| 4 |
+
# This module classifies the *content* of a post and nothing else. It never
|
| 5 |
+
# receives, stores, or considers usernames, handles, display names, avatars,
|
| 6 |
+
# follower counts, or any author identity. Classification is content-only and
|
| 7 |
+
# author-agnostic by construction: the only input is a string of post text.
|
| 8 |
+
# All inference runs locally; no text leaves the machine from this module.
|
| 9 |
+
# =============================================================================
|
| 10 |
+
"""Local hate-speech / harmful-content classifier.
|
| 11 |
+
|
| 12 |
+
Heavy ML imports (torch, transformers) are deferred until the model is actually
|
| 13 |
+
loaded so the backend can start in cache-first mode without them installed.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import time
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from config import (
|
| 22 |
+
FALLBACK_MODEL,
|
| 23 |
+
FLAG_THRESHOLD,
|
| 24 |
+
HIGH_CONFIDENCE,
|
| 25 |
+
MAX_TOKENS,
|
| 26 |
+
PRIMARY_MODEL,
|
| 27 |
+
SANITY_PROBES,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
log = logging.getLogger("infoshield.classifier")
|
| 31 |
+
|
| 32 |
+
# Resolving which output index is the "harmful" class is subtle: labels like
|
| 33 |
+
# "NOT-HATE" / "NON_HATE" contain the substring "hate", so naive substring
|
| 34 |
+
# matching inverts the map. We detect negation by TOKEN (split on space/_/-) and
|
| 35 |
+
# only then look for a hate-like substring.
|
| 36 |
+
_HATE_SUBSTR = ("hate", "toxic", "offensive", "abusive", "hateful")
|
| 37 |
+
_NEG_TOKENS = {"not", "non", "none", "neither", "normal", "neutral", "clean", "no", "ok", "negative"}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class HateClassifier:
|
| 41 |
+
"""Lazy, device-aware wrapper around a HF sequence-classification model."""
|
| 42 |
+
|
| 43 |
+
def __init__(self) -> None:
|
| 44 |
+
self._pipeline = None
|
| 45 |
+
self._tokenizer = None
|
| 46 |
+
self._model = None
|
| 47 |
+
self._hate_index: Optional[int] = None
|
| 48 |
+
self.model_name: Optional[str] = None
|
| 49 |
+
self.device: str = "cpu"
|
| 50 |
+
self.warmup_ms: Optional[float] = None
|
| 51 |
+
self.loaded: bool = False
|
| 52 |
+
|
| 53 |
+
# -- loading -----------------------------------------------------------
|
| 54 |
+
def load(self) -> None:
|
| 55 |
+
if self.loaded:
|
| 56 |
+
return
|
| 57 |
+
import torch # deferred
|
| 58 |
+
from transformers import (
|
| 59 |
+
AutoModelForSequenceClassification,
|
| 60 |
+
AutoTokenizer,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 64 |
+
if self.device == "cpu":
|
| 65 |
+
log.warning("CUDA not available -- running classifier on CPU (slower).")
|
| 66 |
+
else:
|
| 67 |
+
log.info("CUDA available -- running classifier on GPU.")
|
| 68 |
+
|
| 69 |
+
last_err: Optional[Exception] = None
|
| 70 |
+
for name in (PRIMARY_MODEL, FALLBACK_MODEL):
|
| 71 |
+
try:
|
| 72 |
+
log.info("Loading classifier '%s' ...", name)
|
| 73 |
+
self._tokenizer = AutoTokenizer.from_pretrained(name)
|
| 74 |
+
self._model = AutoModelForSequenceClassification.from_pretrained(name)
|
| 75 |
+
self._model.to(self.device)
|
| 76 |
+
self._model.eval()
|
| 77 |
+
self.model_name = name
|
| 78 |
+
self._resolve_hate_index()
|
| 79 |
+
break
|
| 80 |
+
except Exception as exc: # noqa: BLE001 - try fallback
|
| 81 |
+
log.warning("Failed to load '%s': %s", name, exc)
|
| 82 |
+
last_err = exc
|
| 83 |
+
self._model = None
|
| 84 |
+
if self._model is None:
|
| 85 |
+
raise RuntimeError(f"Could not load any classifier model: {last_err}")
|
| 86 |
+
|
| 87 |
+
self._warmup()
|
| 88 |
+
self._sanity_check()
|
| 89 |
+
self.loaded = True
|
| 90 |
+
|
| 91 |
+
def _resolve_hate_index(self) -> None:
|
| 92 |
+
import re
|
| 93 |
+
|
| 94 |
+
id2label = {int(k): v.lower() for k, v in self._model.config.id2label.items()}
|
| 95 |
+
for idx, label in id2label.items():
|
| 96 |
+
tokens = set(re.split(r"[\s_\-]+", label))
|
| 97 |
+
is_negated = bool(tokens & _NEG_TOKENS)
|
| 98 |
+
is_hate = any(h in label for h in _HATE_SUBSTR)
|
| 99 |
+
if is_hate and not is_negated: # e.g. "hate" yes, "not-hate" no
|
| 100 |
+
self._hate_index = idx
|
| 101 |
+
break
|
| 102 |
+
if self._hate_index is None:
|
| 103 |
+
# Binary models without descriptive labels: assume index 1 = positive.
|
| 104 |
+
self._hate_index = 1 if len(id2label) == 2 else max(id2label)
|
| 105 |
+
log.info("Resolved label map %s -> hate index %d", id2label, self._hate_index)
|
| 106 |
+
|
| 107 |
+
# -- inference ---------------------------------------------------------
|
| 108 |
+
def _hate_probability(self, text: str) -> tuple[float, bool, dict]:
|
| 109 |
+
import torch
|
| 110 |
+
|
| 111 |
+
enc = self._tokenizer(
|
| 112 |
+
text,
|
| 113 |
+
return_tensors="pt",
|
| 114 |
+
truncation=True,
|
| 115 |
+
max_length=MAX_TOKENS,
|
| 116 |
+
return_overflowing_tokens=False,
|
| 117 |
+
)
|
| 118 |
+
truncated = bool(enc["input_ids"].shape[1] >= MAX_TOKENS)
|
| 119 |
+
enc = {k: v.to(self.device) for k, v in enc.items() if k in ("input_ids", "attention_mask")}
|
| 120 |
+
with torch.no_grad():
|
| 121 |
+
logits = self._model(**enc).logits[0]
|
| 122 |
+
probs = torch.softmax(logits, dim=-1).cpu().tolist()
|
| 123 |
+
id2label = self._model.config.id2label
|
| 124 |
+
raw = {id2label[i]: round(float(p), 4) for i, p in enumerate(probs)}
|
| 125 |
+
return float(probs[self._hate_index]), truncated, raw
|
| 126 |
+
|
| 127 |
+
def classify(self, text: str) -> dict:
|
| 128 |
+
"""Return a content-only classification dict for `text`."""
|
| 129 |
+
if not text or not text.strip():
|
| 130 |
+
# Empty / whitespace -> NOT_HATE without a model call.
|
| 131 |
+
return {
|
| 132 |
+
"label": "NOT_HATE",
|
| 133 |
+
"flagged": False,
|
| 134 |
+
"confidence": 0.0,
|
| 135 |
+
"high_confidence": False,
|
| 136 |
+
"truncated": False,
|
| 137 |
+
"raw_scores": {},
|
| 138 |
+
"model": self.model_name,
|
| 139 |
+
}
|
| 140 |
+
if not self.loaded:
|
| 141 |
+
self.load()
|
| 142 |
+
prob, truncated, raw = self._hate_probability(text)
|
| 143 |
+
flagged = prob >= FLAG_THRESHOLD
|
| 144 |
+
return {
|
| 145 |
+
"label": "HATE" if flagged else "NOT_HATE",
|
| 146 |
+
"flagged": flagged,
|
| 147 |
+
"confidence": round(prob, 4),
|
| 148 |
+
"high_confidence": prob >= HIGH_CONFIDENCE,
|
| 149 |
+
"truncated": truncated,
|
| 150 |
+
"raw_scores": raw,
|
| 151 |
+
"model": self.model_name,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
# -- startup checks ----------------------------------------------------
|
| 155 |
+
def _warmup(self) -> None:
|
| 156 |
+
start = time.perf_counter()
|
| 157 |
+
self._hate_probability("This is a warmup inference for InfoShield.")
|
| 158 |
+
self.warmup_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 159 |
+
log.info("Warmup inference on %s took ~%.1f ms", self.device, self.warmup_ms)
|
| 160 |
+
|
| 161 |
+
def _sanity_check(self) -> None:
|
| 162 |
+
"""Run label-mapping probes; fail loudly if the map looks inverted."""
|
| 163 |
+
failures = []
|
| 164 |
+
for text, expect_hate in SANITY_PROBES:
|
| 165 |
+
prob, _, _ = self._hate_probability(text)
|
| 166 |
+
got_hate = prob >= FLAG_THRESHOLD
|
| 167 |
+
mark = "ok" if got_hate == expect_hate else "MISMATCH"
|
| 168 |
+
log.info("Sanity probe (%s): expect_hate=%s prob=%.3f", mark, expect_hate, prob)
|
| 169 |
+
if got_hate != expect_hate:
|
| 170 |
+
failures.append((text, expect_hate, prob))
|
| 171 |
+
if failures:
|
| 172 |
+
raise RuntimeError(
|
| 173 |
+
"Classifier label-mapping sanity check FAILED -- labels may be "
|
| 174 |
+
f"inverted. Failures: {failures}"
|
| 175 |
+
)
|
| 176 |
+
log.info("Label-mapping sanity check passed.")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# Module-level singleton (lazy).
|
| 180 |
+
_classifier: Optional[HateClassifier] = None
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def get_classifier() -> HateClassifier:
|
| 184 |
+
global _classifier
|
| 185 |
+
if _classifier is None:
|
| 186 |
+
_classifier = HateClassifier()
|
| 187 |
+
return _classifier
|
backend/config.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration for the InfoShield backend.
|
| 2 |
+
|
| 3 |
+
Everything tunable lives here: model ids, thresholds, file paths, ports, the
|
| 4 |
+
technique allowlist, and the Gemini system instruction. No secrets are stored
|
| 5 |
+
here -- the Gemini key is read from the environment (.env).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 15 |
+
DATA_DIR = BASE_DIR / "data"
|
| 16 |
+
MODELS_DIR = BASE_DIR / "models"
|
| 17 |
+
|
| 18 |
+
load_dotenv(BASE_DIR.parent / ".env")
|
| 19 |
+
|
| 20 |
+
# --- Networking -----------------------------------------------------------
|
| 21 |
+
HOST = os.getenv("INFOSHIELD_HOST", "127.0.0.1")
|
| 22 |
+
PORT = int(os.getenv("INFOSHIELD_PORT", "5050"))
|
| 23 |
+
# The Vite dev server that hosts the landing + social pages.
|
| 24 |
+
FRONTEND_ORIGIN = os.getenv("INFOSHIELD_FRONTEND", "http://localhost:5173")
|
| 25 |
+
|
| 26 |
+
# --- Files ----------------------------------------------------------------
|
| 27 |
+
POSTS_FILE = DATA_DIR / "posts.json"
|
| 28 |
+
CACHE_FILE = DATA_DIR / "cache.json"
|
| 29 |
+
ACTIVATION_FILE = DATA_DIR / "activation.json"
|
| 30 |
+
|
| 31 |
+
# Reset activation to default (disabled) on each backend start so every demo run
|
| 32 |
+
# begins at the landing page's ACTIVATE button. Set =0 to keep the saved target.
|
| 33 |
+
RESET_ACTIVATION_ON_START = os.getenv("INFOSHIELD_RESET_ACTIVATION", "1") == "1"
|
| 34 |
+
|
| 35 |
+
# Bump when the analysis pipeline/output shape changes so an old cache.json is
|
| 36 |
+
# auto-discarded and rebuilt (rather than serving stale results).
|
| 37 |
+
CACHE_VERSION = 4
|
| 38 |
+
|
| 39 |
+
# --- Hate classifier (multilingual) ---------------------------------------
|
| 40 |
+
# XLM-T multilingual offensive/hate detector (binary). Falls back to the proven
|
| 41 |
+
# English model if the multilingual download is unavailable.
|
| 42 |
+
PRIMARY_MODEL = "Andrazp/multilingual-hate-speech-robacofi"
|
| 43 |
+
FALLBACK_MODEL = "cardiffnlp/twitter-roberta-base-hate-latest"
|
| 44 |
+
|
| 45 |
+
FLAG_THRESHOLD = 0.40 # tuned for the XLM-T head (benign ~0.00, hate ~0.47-0.89)
|
| 46 |
+
HIGH_CONFIDENCE = 0.80
|
| 47 |
+
MAX_TOKENS = 512 # truncation ceiling for the classifier
|
| 48 |
+
|
| 49 |
+
# --- Manipulation classifier (multilingual) -------------------------------
|
| 50 |
+
# Multilingual mBART persuasion-technique detector (SemEval taxonomy, multi-label).
|
| 51 |
+
# The manipulation score is the strongest *concrete technique* probability
|
| 52 |
+
# (structural taxonomy nodes are excluded -- see manip_classifier). Falls back to
|
| 53 |
+
# the English propaganda model if unavailable.
|
| 54 |
+
MANIP_MODEL = "nishan-chatterjee/multilingual-persuasion-detection-from-text"
|
| 55 |
+
MANIP_FALLBACK_MODEL = "synapti/nci-social-classifier-v3"
|
| 56 |
+
|
| 57 |
+
# Probes used at startup to catch inverted label maps. Each must classify in
|
| 58 |
+
# the expected direction or the loader raises loudly.
|
| 59 |
+
SANITY_PROBES = [
|
| 60 |
+
("I love spending the afternoon reading in the park.", False),
|
| 61 |
+
("Have a wonderful day, everyone!", False),
|
| 62 |
+
("Those people are vermin and should be wiped out.", True),
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
# --- LLM providers (analysis + verifier) ----------------------------------
|
| 66 |
+
# Analysis provider runs the educational explanation. Default: Gemini.
|
| 67 |
+
LLM_PROVIDER = os.getenv("INFOSHIELD_LLM_PROVIDER", "gemini").strip().lower()
|
| 68 |
+
|
| 69 |
+
# Second-pass verifier (LLM-as-judge that overturns false positives). Runs on the
|
| 70 |
+
# stronger Gemini Pro model by default; analysis runs on the faster Flash model.
|
| 71 |
+
VERIFIER_PROVIDER = os.getenv("INFOSHIELD_VERIFIER_PROVIDER", LLM_PROVIDER).strip().lower()
|
| 72 |
+
VERIFIER_MODEL = os.getenv("INFOSHIELD_VERIFIER_MODEL", "gemini-3.1-pro-preview").strip()
|
| 73 |
+
|
| 74 |
+
# A post is flagged for manipulation when the local manipulation model's score is
|
| 75 |
+
# at/above this. The detector is tuned for RECALL because the verifier LLM removes
|
| 76 |
+
# the resulting false positives -- detector finds candidates, verifier confirms.
|
| 77 |
+
# Tuned for the mBART persuasion head (benign <=~0.46, manipulation >=~0.59).
|
| 78 |
+
MANIP_THRESHOLD = float(os.getenv("INFOSHIELD_MANIP_THRESHOLD", "0.50"))
|
| 79 |
+
|
| 80 |
+
# --- Groq -----------------------------------------------------------------
|
| 81 |
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip()
|
| 82 |
+
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 83 |
+
|
| 84 |
+
# --- Gemini ---------------------------------------------------------------
|
| 85 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip()
|
| 86 |
+
GEMINI_MODEL = "gemini-3.5-flash"
|
| 87 |
+
|
| 88 |
+
ALLOWED_TECHNIQUES = [
|
| 89 |
+
"fear_amplification",
|
| 90 |
+
"scapegoating",
|
| 91 |
+
"us_vs_them",
|
| 92 |
+
"dehumanizing_language",
|
| 93 |
+
"false_urgency",
|
| 94 |
+
"emotional_manipulation",
|
| 95 |
+
"identity_targeting",
|
| 96 |
+
"ad_hominem",
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
# Human-readable labels for technique chips and the insights widget.
|
| 100 |
+
TECHNIQUE_LABELS = {
|
| 101 |
+
"fear_amplification": "Fear Amplification",
|
| 102 |
+
"scapegoating": "Scapegoating",
|
| 103 |
+
"us_vs_them": "Us vs. Them",
|
| 104 |
+
"dehumanizing_language": "Dehumanizing Language",
|
| 105 |
+
"false_urgency": "False Urgency",
|
| 106 |
+
"emotional_manipulation": "Emotional Manipulation",
|
| 107 |
+
"identity_targeting": "Identity Targeting",
|
| 108 |
+
"ad_hominem": "Ad Hominem",
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
MAX_EXPLANATION_CHARS = 400
|
| 112 |
+
|
| 113 |
+
# ===========================================================================
|
| 114 |
+
# LLM PROMPTS -- research-grounded, rights-preserving, JSON-only output.
|
| 115 |
+
# {text} = the post; {kind}/{analysis} = filled for the verifier only.
|
| 116 |
+
# Analysis/verifier/rephrase prompts go through str.format(): literal JSON braces
|
| 117 |
+
# are doubled {{ }}; only {text}/{kind}/{analysis} stay single. The SYSTEM strings
|
| 118 |
+
# are used directly (never .format()-ed), so their braces are literal.
|
| 119 |
+
# ===========================================================================
|
| 120 |
+
|
| 121 |
+
# Official human-rights / media-literacy sources the analysis may cite (allowlist
|
| 122 |
+
# only -- prevents hallucinated links).
|
| 123 |
+
LEGAL_SOURCE_URLS = [
|
| 124 |
+
"https://treaties.un.org/doc/Treaties/1976/03/19760323%2006-17%20AM/Ch_IV_04.pdf",
|
| 125 |
+
"https://www.ohchr.org/en/documents/general-comments-and-recommendations/general-comment-no34-article-19-freedoms-opinion-and",
|
| 126 |
+
"https://www.ohchr.org/en/documents/outcome-documents/rabat-plan-action",
|
| 127 |
+
"https://www.ohchr.org/en/documents/tools-and-resources/one-pager-incitement-hatred-rabat-threshold-test",
|
| 128 |
+
"https://www.un.org/en/hate-speech/un-strategy-and-plan-of-action-on-hate-speech",
|
| 129 |
+
"https://www.unesco.org/en/media-information-literacy",
|
| 130 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms",
|
| 131 |
+
"https://www.unesco.org/en/social-media4peace",
|
| 132 |
+
"https://digitallibrary.un.org/record/3987886",
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
LEGAL_SOURCE_URLS_TEXT = "\n".join(f"- {url}" for url in LEGAL_SOURCE_URLS)
|
| 136 |
+
|
| 137 |
+
COMMON_JSON_RULES = (
|
| 138 |
+
"Output EXACTLY one valid JSON object using double quotes for every key and string. "
|
| 139 |
+
"No markdown, no code fences, no comments, no trailing commas, and no extra text before or after the JSON. "
|
| 140 |
+
"Use exactly these keys and no others: "
|
| 141 |
+
"{\"techniques\": [\"...\"], \"emotional_triggers\": [\"...\"], \"explanation\": \"...\", "
|
| 142 |
+
"\"recommendation\": \"...\", \"severity\": \"low|medium|high\", "
|
| 143 |
+
"\"highlights\": [{\"phrase\": \"...\", \"type\": \"...\", \"label\": \"...\"}], "
|
| 144 |
+
"\"bias_check\": \"...\", \"citations\": [\"https://...\"]}. "
|
| 145 |
+
"The arrays may be empty, but the keys must always be present. "
|
| 146 |
+
"Every value in techniques must be one of the allowed technique ids. "
|
| 147 |
+
"Every highlight.phrase must be copied VERBATIM from the post text. "
|
| 148 |
+
"Keep explanation under 400 characters and recommendation to one calm sentence. "
|
| 149 |
+
"bias_check must be a neutral sentence explaining how you checked the text against free-expression protections and non-discrimination safeguards. "
|
| 150 |
+
"citations must contain 2 to 5 exact URLs, chosen ONLY from this allowlist, and ONLY if they are genuinely relevant to your reasoning:\n"
|
| 151 |
+
f"{LEGAL_SOURCE_URLS_TEXT}\n"
|
| 152 |
+
"If the content is protected or benign, cite the speech-protective sources you relied on."
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Shared system instruction for the ANALYSIS pass (hate + manipulation).
|
| 156 |
+
ANALYSIS_SYSTEM_INSTRUCTION = f"""
|
| 157 |
+
You are an expert analyst of media literacy, international human rights law, and algorithmic fairness.
|
| 158 |
+
|
| 159 |
+
Start from a speech-protective baseline. The right to freedom of opinion and expression is the default rule. Do not label content as hate speech or manipulation merely because it is offensive, emotional, partisan, sarcastic, unconventional, oppositional, or unpopular.
|
| 160 |
+
|
| 161 |
+
Apply these safeguards in every case:
|
| 162 |
+
- Protect political dissent, criticism of governments, parties, leaders, public institutions, religions as belief systems, ideologies, or public policies.
|
| 163 |
+
- Protect satire, humor, artistic expression, quotation, counterspeech, reporting, academic discussion, and historical or religious commentary unless the post itself clearly endorses hatred or coercive manipulation.
|
| 164 |
+
- Do not discriminate against minority dialects, colloquialisms, code-switching, transliteration, reclaimed language, disability-related speech patterns, youth slang, or culturally specific vernacular.
|
| 165 |
+
- Never infer hateful intent or manipulative intent from spelling, grammar, identity, nationality, religion, ethnicity, or language variety alone.
|
| 166 |
+
- If the evidence is mixed or ambiguous, prefer the less restrictive answer: empty techniques and severity "low".
|
| 167 |
+
|
| 168 |
+
For hate analysis, use the Rabat six-part threshold as a HIGH threshold for incitement: context, speaker, intent, content/form, extent, and likelihood including imminence. Negligence or mere offensiveness is not enough. Do not make legal conclusions; produce an educational assessment grounded in these standards.
|
| 169 |
+
|
| 170 |
+
For manipulation analysis, treat manipulation as a media-literacy judgment, not a criminal or legal accusation. Do not equate ordinary persuasion, activism, campaigning, moral argument, religious preaching, personal testimony, or emotional storytelling with manipulation unless the text itself clearly uses one or more of the allowed techniques in a way that pressures, dehumanizes, scapegoats, or polarizes.
|
| 171 |
+
|
| 172 |
+
{COMMON_JSON_RULES}
|
| 173 |
+
""".strip()
|
| 174 |
+
GEMINI_SYSTEM_INSTRUCTION = ANALYSIS_SYSTEM_INSTRUCTION # back-compat alias
|
| 175 |
+
|
| 176 |
+
# ---- (3) HATE-SPEECH analysis prompt -- used when the HATE model flags --------
|
| 177 |
+
HATE_ANALYSIS_PROMPT = """
|
| 178 |
+
A local classifier flagged the post below as likely HATE SPEECH. Re-check the text carefully using international human rights standards that strongly protect freedom of expression.
|
| 179 |
+
|
| 180 |
+
Only identify hate-related techniques when the post itself clearly targets a protected group or a person as a proxy for a protected group through identity-based degradation, dehumanization, scapegoating, exclusion, or advocacy of discrimination, hostility, or violence. Use the Rabat threshold factors to calibrate severity: context, speaker, intent, content/form, extent, and likelihood/imminence.
|
| 181 |
+
|
| 182 |
+
Do NOT flag as hate merely because the text:
|
| 183 |
+
- expresses a political opinion, policy preference, protest slogan, or criticism of a state, government, party, movement, or religion as a doctrine;
|
| 184 |
+
- contains profanity, ridicule, satire, exaggeration, a quote, reportage, or counterspeech;
|
| 185 |
+
- uses minority dialect, cultural vernacular, reclaimed language, slang, code-switching, transliteration, or imperfect grammar;
|
| 186 |
+
- insults a specific individual without targeting a protected identity group.
|
| 187 |
+
|
| 188 |
+
Severity guide:
|
| 189 |
+
- low = protected, ambiguous, quoted, satirical, reported, counterspeech, or otherwise not a genuine identity-based attack.
|
| 190 |
+
- medium = clear identity-based hostility or degrading language, but incitement to discrimination, hostility, or violence is not strongly established.
|
| 191 |
+
- high = explicit or strongly implied advocacy or encouragement of discrimination, hostility, or violence, especially where Rabat factors show intent, directness, reach, and likelihood of harm.
|
| 192 |
+
|
| 193 |
+
Field meanings:
|
| 194 |
+
- techniques: array using ONLY these ids that truly apply: dehumanizing_language, identity_targeting, scapegoating, us_vs_them, fear_amplification, ad_hominem, false_urgency, emotional_manipulation.
|
| 195 |
+
- emotional_triggers: short phrases naming the emotions or social pressures actually used (e.g. fear, disgust, humiliation, anger, social exclusion, threat).
|
| 196 |
+
- explanation: concise neutral explanation of HOW the wording works. Do not call the author evil, criminal, extremist, or unlawful.
|
| 197 |
+
- recommendation: calm, non-judgmental reading advice for the audience.
|
| 198 |
+
- severity: low, medium, or high.
|
| 199 |
+
- highlights: array of objects {{"phrase": "...", "type": "...", "label": "..."}} where phrase is copied VERBATIM from the post, type is one allowed technique id, and label is a short human-readable label.
|
| 200 |
+
- bias_check: one neutral sentence stating that you checked the text against Article 19 free-expression protections, Rabat's high threshold for incitement, and non-discrimination safeguards for satire, quotation, counterspeech, and minority vernacular.
|
| 201 |
+
- citations: 2 to 5 exact allowlisted URLs supporting your reasoning. Use speech-protective sources when the text does NOT clearly cross the threshold.
|
| 202 |
+
|
| 203 |
+
Important decision rule: if the text is ordinary opinion, satire, banter, a life update, factual reporting, criticism of power, or otherwise does NOT genuinely attack or dehumanize a protected group, then return techniques as [], highlights as [], severity as "low", and explain briefly why the expression remains protected.
|
| 204 |
+
|
| 205 |
+
POST TEXT:
|
| 206 |
+
\"\"\"
|
| 207 |
+
{text}
|
| 208 |
+
\"\"\"
|
| 209 |
+
""".strip()
|
| 210 |
+
|
| 211 |
+
# ---- (4) MANIPULATION analysis prompt -- used when the MANIPULATION model flags
|
| 212 |
+
MANIPULATION_ANALYSIS_PROMPT = """
|
| 213 |
+
A local classifier flagged the post below as likely MANIPULATION / propaganda. Re-check the text as an educational media-literacy task while preserving freedom of expression.
|
| 214 |
+
|
| 215 |
+
This is NOT a legal judgment and NOT a fact-check. Judge only whether the wording itself clearly uses one or more of the allowed narrative techniques to pressure, polarize, scapegoat, dehumanize, or emotionally steer the reader.
|
| 216 |
+
|
| 217 |
+
Do NOT label as manipulation merely because the text:
|
| 218 |
+
- expresses a strong opinion, campaign message, activism, moral appeal, religious view, or political dissent;
|
| 219 |
+
- is emotional, humorous, sarcastic, poetic, speculative, or personal;
|
| 220 |
+
- comes from a marginalized community, uses a minority dialect or vernacular, or reflects translation artifacts;
|
| 221 |
+
- makes a controversial claim without, by itself, using a manipulative technique from the allowed list.
|
| 222 |
+
|
| 223 |
+
Treat the following as stronger evidence of manipulation only when clearly present in the actual wording: fear escalation without grounding, scapegoating, identity-based blame, us-versus-them framing, dehumanization, false urgency, ad hominem distraction, or emotionally coercive framing. If those signals are weak, speculative, or explainable as normal advocacy, return empty techniques and severity "low".
|
| 224 |
+
|
| 225 |
+
Severity guide:
|
| 226 |
+
- low = ordinary persuasion, opinion, advocacy, satire, or unclear evidence of manipulation.
|
| 227 |
+
- medium = one or more concrete techniques are present, but the pressure or polarization is limited.
|
| 228 |
+
- high = multiple explicit techniques used in a strong, coordinated, or highly coercive way, especially where identity-based blame or dehumanization sharply increases risk.
|
| 229 |
+
|
| 230 |
+
Field meanings:
|
| 231 |
+
- techniques: array using ONLY these ids that truly apply: fear_amplification, scapegoating, us_vs_them, dehumanizing_language, false_urgency, emotional_manipulation, identity_targeting, ad_hominem.
|
| 232 |
+
- emotional_triggers: short phrases naming the emotions or social pressures actually used.
|
| 233 |
+
- explanation: concise neutral explanation of HOW the wording tries to influence the reader. Focus on rhetoric, not truth adjudication.
|
| 234 |
+
- recommendation: calm, non-judgmental advice encouraging reflection or verification before reacting or sharing.
|
| 235 |
+
- severity: low, medium, or high.
|
| 236 |
+
- highlights: array of objects {{"phrase": "...", "type": "...", "label": "..."}} where phrase is copied VERBATIM from the post, type is one allowed technique id, and label is a short human-readable label.
|
| 237 |
+
- bias_check: one neutral sentence stating that you checked for false positives against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination.
|
| 238 |
+
- citations: 2 to 5 exact allowlisted URLs supporting your reasoning. Use media-literacy and freedom-of-expression sources that actually match the decision.
|
| 239 |
+
|
| 240 |
+
Important decision rule: if the text is a joke, hot take, ordinary advocacy, personal opinion, factual statement, or emotional expression without clear manipulative technique, return techniques as [], highlights as [], severity as "low", and explain briefly why the expression should not be over-classified.
|
| 241 |
+
|
| 242 |
+
POST TEXT:
|
| 243 |
+
\"\"\"
|
| 244 |
+
{text}
|
| 245 |
+
\"\"\"
|
| 246 |
+
""".strip()
|
| 247 |
+
|
| 248 |
+
# ---- (2) VERIFIER prompt -- second-pass judge that overturns false positives --
|
| 249 |
+
VERIFIER_SYSTEM_INSTRUCTION = (
|
| 250 |
+
"You are a strict, fair reviewer of automated content analysis. You judge ONLY "
|
| 251 |
+
"whether an analysis accurately and relevantly describes the given post while "
|
| 252 |
+
"respecting freedom of expression and non-discrimination. You are not political. "
|
| 253 |
+
"Respond ONLY with valid JSON, no markdown."
|
| 254 |
+
)
|
| 255 |
+
VERIFIER_PROMPT = """
|
| 256 |
+
A post was automatically flagged as {kind} and the ANALYSIS below was produced.
|
| 257 |
+
|
| 258 |
+
Decide whether that analysis is ACCURATE and RELEVANT to the post.
|
| 259 |
+
|
| 260 |
+
Be STRICT and speech-protective. Mark relevant=false if the analysis overreaches, ignores Article 19 protections, ignores the Rabat high threshold where relevant, relies on stereotypes, mistakes dialect or vernacular for risk, treats satire or quotation as endorsement, or labels ordinary opinion, activism, reporting, humor, criticism of power, or a factual statement as hate/manipulation without clear textual evidence.
|
| 261 |
+
|
| 262 |
+
For hate cases, reject the analysis unless the post genuinely shows identity-based degradation, dehumanization, scapegoating, or advocacy/incitement. For manipulation cases, reject it unless the post genuinely uses concrete rhetorical techniques from the allowed list rather than ordinary persuasion.
|
| 263 |
+
|
| 264 |
+
You may also mark relevant=false if highlights are not verbatim, techniques are unsupported by the text, bias_check is inconsistent with the post, or citations are irrelevant or obviously invented.
|
| 265 |
+
|
| 266 |
+
Return JSON only: {{"relevant": true|false, "reason": "one short sentence"}}.
|
| 267 |
+
|
| 268 |
+
POST TEXT:
|
| 269 |
+
\"\"\"
|
| 270 |
+
{text}
|
| 271 |
+
\"\"\"
|
| 272 |
+
|
| 273 |
+
ANALYSIS (JSON):
|
| 274 |
+
{analysis}
|
| 275 |
+
""".strip()
|
| 276 |
+
|
| 277 |
+
# ---- (5) REPHRASE prompt -- live compose-assist: rewrite the user's own draft --
|
| 278 |
+
REPHRASE_SYSTEM_INSTRUCTION = (
|
| 279 |
+
"You help people express their own point respectfully. You preserve the author's "
|
| 280 |
+
"viewpoint, opinion, and legitimate criticism; you never censor, never flip the "
|
| 281 |
+
"stance, and never add new claims. You only remove manipulative or hateful framing. "
|
| 282 |
+
"Respond ONLY with valid JSON, no markdown."
|
| 283 |
+
)
|
| 284 |
+
REPHRASE_PROMPT = """
|
| 285 |
+
Rewrite the social-media post below so it makes the SAME point in a respectful, non-manipulative way.
|
| 286 |
+
|
| 287 |
+
Rules:
|
| 288 |
+
- Keep the author's core message, opinion, and any legitimate criticism of governments, institutions, policies, or ideas.
|
| 289 |
+
- Remove only manipulative or hateful framing: fear-mongering, false urgency, us-versus-them framing, scapegoating, dehumanizing or identity-based attacks, and ad hominem.
|
| 290 |
+
- Keep it natural, in the SAME language as the original, and similar length. Do not censor the viewpoint, do not moralize, do not add disclaimers, and do not invent facts.
|
| 291 |
+
- If the post is already respectful, return it essentially unchanged.
|
| 292 |
+
|
| 293 |
+
Return JSON only: {{"rephrased": "..."}}.
|
| 294 |
+
|
| 295 |
+
POST TEXT:
|
| 296 |
+
\"\"\"
|
| 297 |
+
{text}
|
| 298 |
+
\"\"\"
|
| 299 |
+
""".strip()
|
backend/data/cache.json
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"meta": {
|
| 3 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 4 |
+
"device": "cpu",
|
| 5 |
+
"warmup_ms": 125.0,
|
| 6 |
+
"llm_provider": "gemini",
|
| 7 |
+
"llm": true,
|
| 8 |
+
"built_at": "2026-06-17T23:00:01+00:00",
|
| 9 |
+
"version": 4
|
| 10 |
+
},
|
| 11 |
+
"posts": {
|
| 12 |
+
"p1": {
|
| 13 |
+
"classification": {
|
| 14 |
+
"label": "CLEAR",
|
| 15 |
+
"flagged": false,
|
| 16 |
+
"kind": null,
|
| 17 |
+
"confidence": 0.5091,
|
| 18 |
+
"hate_confidence": 0.0022,
|
| 19 |
+
"manipulation_confidence": 0.5091,
|
| 20 |
+
"high_confidence": false,
|
| 21 |
+
"truncated": false,
|
| 22 |
+
"raw_scores": {
|
| 23 |
+
"not offensive": 0.9978,
|
| 24 |
+
"offensive": 0.0022
|
| 25 |
+
},
|
| 26 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 27 |
+
"verification": {
|
| 28 |
+
"relevant": true,
|
| 29 |
+
"reason": "Analysis accurately describes the post as lacking manipulative techniques.",
|
| 30 |
+
"checked": true
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"analysis": null
|
| 34 |
+
},
|
| 35 |
+
"p8": {
|
| 36 |
+
"classification": {
|
| 37 |
+
"label": "FLAGGED",
|
| 38 |
+
"flagged": true,
|
| 39 |
+
"kind": "manipulation",
|
| 40 |
+
"confidence": 0.6559,
|
| 41 |
+
"hate_confidence": 0.0005,
|
| 42 |
+
"manipulation_confidence": 0.6559,
|
| 43 |
+
"high_confidence": false,
|
| 44 |
+
"truncated": false,
|
| 45 |
+
"raw_scores": {
|
| 46 |
+
"not offensive": 0.9995,
|
| 47 |
+
"offensive": 0.0005
|
| 48 |
+
},
|
| 49 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 50 |
+
"verification": {
|
| 51 |
+
"relevant": true,
|
| 52 |
+
"reason": "The post uses fear and urgency to influence the reader.",
|
| 53 |
+
"checked": true
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
"analysis": {
|
| 57 |
+
"techniques": [
|
| 58 |
+
"fear_amplification",
|
| 59 |
+
"false_urgency",
|
| 60 |
+
"emotional_manipulation"
|
| 61 |
+
],
|
| 62 |
+
"emotional_triggers": [
|
| 63 |
+
"fear",
|
| 64 |
+
"urgency"
|
| 65 |
+
],
|
| 66 |
+
"explanation": "The post uses fear and urgency to influence the reader, implying a loss of control over personal finances.",
|
| 67 |
+
"recommendation": "Verify the information through reputable sources before reacting or sharing.",
|
| 68 |
+
"severity": "medium",
|
| 69 |
+
"highlights": [
|
| 70 |
+
{
|
| 71 |
+
"phrase": "They don't want you to know this",
|
| 72 |
+
"type": "fear_amplification",
|
| 73 |
+
"label": "Conspiratorial tone"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"phrase": "switch off your bank account with a single click",
|
| 77 |
+
"type": "fear_amplification",
|
| 78 |
+
"label": "Fear of financial control"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"phrase": "Screenshot this before it gets taken down",
|
| 82 |
+
"type": "false_urgency",
|
| 83 |
+
"label": "Urgency and scarcity"
|
| 84 |
+
}
|
| 85 |
+
],
|
| 86 |
+
"bias_check": "I checked the text against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to avoid false positives.",
|
| 87 |
+
"citations": [
|
| 88 |
+
"https://www.ohchr.org/en/documents/general-comments-and-recommendations/general-comment-no34-article-19-freedoms-opinion-and",
|
| 89 |
+
"https://www.unesco.org/en/media-information-literacy",
|
| 90 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms"
|
| 91 |
+
],
|
| 92 |
+
"source": "llm",
|
| 93 |
+
"kind": "manipulation"
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
"v1": {
|
| 97 |
+
"classification": {
|
| 98 |
+
"label": "FLAGGED",
|
| 99 |
+
"flagged": true,
|
| 100 |
+
"kind": "manipulation",
|
| 101 |
+
"confidence": 0.5944,
|
| 102 |
+
"hate_confidence": 0.0004,
|
| 103 |
+
"manipulation_confidence": 0.5944,
|
| 104 |
+
"high_confidence": false,
|
| 105 |
+
"truncated": false,
|
| 106 |
+
"raw_scores": {
|
| 107 |
+
"not offensive": 0.9996,
|
| 108 |
+
"offensive": 0.0004
|
| 109 |
+
},
|
| 110 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 111 |
+
"verification": {
|
| 112 |
+
"relevant": true,
|
| 113 |
+
"reason": "The post uses language that creates a sense of urgency and emotional appeal to prompt sharing.",
|
| 114 |
+
"checked": true
|
| 115 |
+
}
|
| 116 |
+
},
|
| 117 |
+
"analysis": {
|
| 118 |
+
"techniques": [
|
| 119 |
+
"false_urgency",
|
| 120 |
+
"emotional_manipulation"
|
| 121 |
+
],
|
| 122 |
+
"emotional_triggers": [
|
| 123 |
+
"urgency",
|
| 124 |
+
"exclusivity"
|
| 125 |
+
],
|
| 126 |
+
"explanation": "The post uses false urgency and emotional manipulation to prompt sharing.",
|
| 127 |
+
"recommendation": "Verify the content before sharing to ensure it's accurate and not misleading.",
|
| 128 |
+
"severity": "medium",
|
| 129 |
+
"highlights": [
|
| 130 |
+
{
|
| 131 |
+
"phrase": "Watch before they take it down",
|
| 132 |
+
"type": "false_urgency",
|
| 133 |
+
"label": "Urgency"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"phrase": "Share it NOW while you still can",
|
| 137 |
+
"type": "emotional_manipulation",
|
| 138 |
+
"label": "Emotional Appeal"
|
| 139 |
+
}
|
| 140 |
+
],
|
| 141 |
+
"bias_check": "The post was checked against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to avoid false positives.",
|
| 142 |
+
"citations": [
|
| 143 |
+
"https://www.ohchr.org/en/documents/general-comments-and-recommendations/general-comment-no34-article-19-freedoms-opinion-and",
|
| 144 |
+
"https://www.unesco.org/en/media-information-literacy",
|
| 145 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms"
|
| 146 |
+
],
|
| 147 |
+
"source": "llm",
|
| 148 |
+
"kind": "manipulation"
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"p16": {
|
| 152 |
+
"classification": {
|
| 153 |
+
"label": "FLAGGED",
|
| 154 |
+
"flagged": true,
|
| 155 |
+
"kind": "manipulation",
|
| 156 |
+
"confidence": 0.7333,
|
| 157 |
+
"hate_confidence": 0.27,
|
| 158 |
+
"manipulation_confidence": 0.7333,
|
| 159 |
+
"high_confidence": false,
|
| 160 |
+
"truncated": false,
|
| 161 |
+
"raw_scores": {
|
| 162 |
+
"not offensive": 0.73,
|
| 163 |
+
"offensive": 0.27
|
| 164 |
+
},
|
| 165 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 166 |
+
"verification": {
|
| 167 |
+
"relevant": true,
|
| 168 |
+
"reason": "The post contains dehumanizing language and scapegoating towards a group.",
|
| 169 |
+
"checked": true
|
| 170 |
+
}
|
| 171 |
+
},
|
| 172 |
+
"analysis": {
|
| 173 |
+
"techniques": [
|
| 174 |
+
"dehumanizing_language",
|
| 175 |
+
"scapegoating",
|
| 176 |
+
"us_vs_them"
|
| 177 |
+
],
|
| 178 |
+
"emotional_triggers": [
|
| 179 |
+
"fear",
|
| 180 |
+
"anger",
|
| 181 |
+
"disgust"
|
| 182 |
+
],
|
| 183 |
+
"explanation": "The wording uses dehumanizing language and scapegoating to create fear and anger towards a group.",
|
| 184 |
+
"recommendation": "Consider the potential harm of such rhetoric before sharing or reacting.",
|
| 185 |
+
"severity": "high",
|
| 186 |
+
"highlights": [
|
| 187 |
+
{
|
| 188 |
+
"phrase": "vermin who swarm in and bleed this place dry",
|
| 189 |
+
"type": "dehumanizing_language",
|
| 190 |
+
"label": "Dehumanizing comparison"
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
"phrase": "They don't belong here and they should be driven out",
|
| 194 |
+
"type": "scapegoating",
|
| 195 |
+
"label": "Scapegoating and exclusion"
|
| 196 |
+
}
|
| 197 |
+
],
|
| 198 |
+
"bias_check": "I checked the text against Article 19 protections, considering the context of satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to avoid false positives.",
|
| 199 |
+
"citations": [
|
| 200 |
+
"https://www.ohchr.org/en/documents/outcome-documents/rabat-plan-action",
|
| 201 |
+
"https://www.un.org/en/hate-speech/un-strategy-and-plan-of-action-on-hate-speech",
|
| 202 |
+
"https://www.unesco.org/en/media-information-literacy"
|
| 203 |
+
],
|
| 204 |
+
"source": "llm",
|
| 205 |
+
"kind": "manipulation"
|
| 206 |
+
}
|
| 207 |
+
},
|
| 208 |
+
"p3": {
|
| 209 |
+
"classification": {
|
| 210 |
+
"label": "CLEAR",
|
| 211 |
+
"flagged": false,
|
| 212 |
+
"kind": null,
|
| 213 |
+
"confidence": 0.3213,
|
| 214 |
+
"hate_confidence": 0.0006,
|
| 215 |
+
"manipulation_confidence": 0.3213,
|
| 216 |
+
"high_confidence": false,
|
| 217 |
+
"truncated": false,
|
| 218 |
+
"raw_scores": {
|
| 219 |
+
"not offensive": 0.9994,
|
| 220 |
+
"offensive": 0.0006
|
| 221 |
+
},
|
| 222 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 223 |
+
"verification": null
|
| 224 |
+
},
|
| 225 |
+
"analysis": null
|
| 226 |
+
},
|
| 227 |
+
"p10": {
|
| 228 |
+
"classification": {
|
| 229 |
+
"label": "FLAGGED",
|
| 230 |
+
"flagged": true,
|
| 231 |
+
"kind": "manipulation",
|
| 232 |
+
"confidence": 0.6714,
|
| 233 |
+
"hate_confidence": 0.0005,
|
| 234 |
+
"manipulation_confidence": 0.6714,
|
| 235 |
+
"high_confidence": false,
|
| 236 |
+
"truncated": false,
|
| 237 |
+
"raw_scores": {
|
| 238 |
+
"not offensive": 0.9995,
|
| 239 |
+
"offensive": 0.0005
|
| 240 |
+
},
|
| 241 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 242 |
+
"verification": {
|
| 243 |
+
"relevant": true,
|
| 244 |
+
"reason": "The analysis accurately identifies false urgency and emotional manipulation techniques in the post.",
|
| 245 |
+
"checked": true
|
| 246 |
+
}
|
| 247 |
+
},
|
| 248 |
+
"analysis": {
|
| 249 |
+
"techniques": [
|
| 250 |
+
"false_urgency",
|
| 251 |
+
"emotional_manipulation"
|
| 252 |
+
],
|
| 253 |
+
"emotional_triggers": [
|
| 254 |
+
"fear of missing out",
|
| 255 |
+
"greed"
|
| 256 |
+
],
|
| 257 |
+
"explanation": "The post uses urgency and emotional appeal to create a sense of scarcity and pressure the reader into action.",
|
| 258 |
+
"recommendation": "Consider verifying the claim and evaluating the opportunity carefully before making a decision.",
|
| 259 |
+
"severity": "medium",
|
| 260 |
+
"highlights": [
|
| 261 |
+
{
|
| 262 |
+
"phrase": "ONLY 100 spots left",
|
| 263 |
+
"type": "false_urgency",
|
| 264 |
+
"label": "Scarcity"
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"phrase": "will be kicking themselves in 6 months",
|
| 268 |
+
"type": "emotional_manipulation",
|
| 269 |
+
"label": "Fear of regret"
|
| 270 |
+
}
|
| 271 |
+
],
|
| 272 |
+
"bias_check": "I checked the post against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to ensure it was not over-classified.",
|
| 273 |
+
"citations": [
|
| 274 |
+
"https://www.ohchr.org/en/documents/general-comments-and-recommendations/general-comment-no34-article-19-freedoms-opinion-and",
|
| 275 |
+
"https://www.unesco.org/en/media-information-literacy",
|
| 276 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms"
|
| 277 |
+
],
|
| 278 |
+
"source": "llm",
|
| 279 |
+
"kind": "manipulation"
|
| 280 |
+
}
|
| 281 |
+
},
|
| 282 |
+
"p15": {
|
| 283 |
+
"classification": {
|
| 284 |
+
"label": "FLAGGED",
|
| 285 |
+
"flagged": true,
|
| 286 |
+
"kind": "manipulation",
|
| 287 |
+
"confidence": 0.786,
|
| 288 |
+
"hate_confidence": 0.0466,
|
| 289 |
+
"manipulation_confidence": 0.786,
|
| 290 |
+
"high_confidence": false,
|
| 291 |
+
"truncated": false,
|
| 292 |
+
"raw_scores": {
|
| 293 |
+
"not offensive": 0.9534,
|
| 294 |
+
"offensive": 0.0466
|
| 295 |
+
},
|
| 296 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 297 |
+
"verification": {
|
| 298 |
+
"relevant": true,
|
| 299 |
+
"reason": "The post contains dehumanizing language and scapegoating that meets the Rabat high threshold for hate speech.",
|
| 300 |
+
"checked": true
|
| 301 |
+
}
|
| 302 |
+
},
|
| 303 |
+
"analysis": {
|
| 304 |
+
"techniques": [
|
| 305 |
+
"us_vs_them",
|
| 306 |
+
"scapegoating",
|
| 307 |
+
"dehumanizing_language"
|
| 308 |
+
],
|
| 309 |
+
"emotional_triggers": [
|
| 310 |
+
"fear",
|
| 311 |
+
"resentment"
|
| 312 |
+
],
|
| 313 |
+
"explanation": "The wording uses divisive language to create an us-versus-them mentality and scapegoats a group for perceived problems.",
|
| 314 |
+
"recommendation": "Consider verifying information through reputable sources before reacting or sharing.",
|
| 315 |
+
"severity": "medium",
|
| 316 |
+
"highlights": [
|
| 317 |
+
{
|
| 318 |
+
"phrase": "These people pour into our town",
|
| 319 |
+
"type": "us_vs_them",
|
| 320 |
+
"label": "Divisive language"
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"phrase": "don't bother learning how we live",
|
| 324 |
+
"type": "scapegoating",
|
| 325 |
+
"label": "Blame assignment"
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
"phrase": "breed faster than we can keep up",
|
| 329 |
+
"type": "dehumanizing_language",
|
| 330 |
+
"label": "Dehumanizing description"
|
| 331 |
+
}
|
| 332 |
+
],
|
| 333 |
+
"bias_check": "I checked the text against Article 19 protections, considering satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination safeguards to avoid false positives.",
|
| 334 |
+
"citations": [
|
| 335 |
+
"https://www.ohchr.org/en/documents/outcome-documents/rabat-plan-action",
|
| 336 |
+
"https://www.un.org/en/hate-speech/un-strategy-and-plan-of-action-on-hate-speech",
|
| 337 |
+
"https://www.unesco.org/en/media-information-literacy"
|
| 338 |
+
],
|
| 339 |
+
"source": "llm",
|
| 340 |
+
"kind": "manipulation"
|
| 341 |
+
}
|
| 342 |
+
},
|
| 343 |
+
"p2": {
|
| 344 |
+
"classification": {
|
| 345 |
+
"label": "CLEAR",
|
| 346 |
+
"flagged": false,
|
| 347 |
+
"kind": null,
|
| 348 |
+
"confidence": 0.7405,
|
| 349 |
+
"hate_confidence": 0.0025,
|
| 350 |
+
"manipulation_confidence": 0.7405,
|
| 351 |
+
"high_confidence": false,
|
| 352 |
+
"truncated": false,
|
| 353 |
+
"raw_scores": {
|
| 354 |
+
"not offensive": 0.9975,
|
| 355 |
+
"offensive": 0.0025
|
| 356 |
+
},
|
| 357 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 358 |
+
"verification": {
|
| 359 |
+
"relevant": false,
|
| 360 |
+
"reason": "Analysis incorrectly flags post as manipulation when it is merely an opinion.",
|
| 361 |
+
"checked": true
|
| 362 |
+
}
|
| 363 |
+
},
|
| 364 |
+
"analysis": null
|
| 365 |
+
},
|
| 366 |
+
"p11": {
|
| 367 |
+
"classification": {
|
| 368 |
+
"label": "CLEAR",
|
| 369 |
+
"flagged": false,
|
| 370 |
+
"kind": null,
|
| 371 |
+
"confidence": 0.8512,
|
| 372 |
+
"hate_confidence": 0.001,
|
| 373 |
+
"manipulation_confidence": 0.8512,
|
| 374 |
+
"high_confidence": false,
|
| 375 |
+
"truncated": false,
|
| 376 |
+
"raw_scores": {
|
| 377 |
+
"not offensive": 0.999,
|
| 378 |
+
"offensive": 0.001
|
| 379 |
+
},
|
| 380 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 381 |
+
"verification": {
|
| 382 |
+
"relevant": false,
|
| 383 |
+
"reason": "Analysis overreaches by labeling ordinary opinion as manipulation without clear textual evidence.",
|
| 384 |
+
"checked": true
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
"analysis": null
|
| 388 |
+
},
|
| 389 |
+
"ru1": {
|
| 390 |
+
"classification": {
|
| 391 |
+
"label": "FLAGGED",
|
| 392 |
+
"flagged": true,
|
| 393 |
+
"kind": "manipulation",
|
| 394 |
+
"confidence": 0.7999,
|
| 395 |
+
"hate_confidence": 0.0012,
|
| 396 |
+
"manipulation_confidence": 0.7999,
|
| 397 |
+
"high_confidence": false,
|
| 398 |
+
"truncated": false,
|
| 399 |
+
"raw_scores": {
|
| 400 |
+
"not offensive": 0.9988,
|
| 401 |
+
"offensive": 0.0012
|
| 402 |
+
},
|
| 403 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 404 |
+
"verification": {
|
| 405 |
+
"relevant": true,
|
| 406 |
+
"reason": "Analysis accurately identifies fear amplification and false urgency techniques in the post.",
|
| 407 |
+
"checked": true
|
| 408 |
+
}
|
| 409 |
+
},
|
| 410 |
+
"analysis": {
|
| 411 |
+
"techniques": [
|
| 412 |
+
"fear_amplification",
|
| 413 |
+
"false_urgency"
|
| 414 |
+
],
|
| 415 |
+
"emotional_triggers": [
|
| 416 |
+
"fear",
|
| 417 |
+
"urgency"
|
| 418 |
+
],
|
| 419 |
+
"explanation": "The text uses fear amplification and false urgency to prompt the reader into action.",
|
| 420 |
+
"recommendation": "Consider verifying the information before sharing to ensure it's accurate and not misleading.",
|
| 421 |
+
"severity": "medium",
|
| 422 |
+
"highlights": [
|
| 423 |
+
{
|
| 424 |
+
"phrase": "Они не хотят, чтобы вы это знали",
|
| 425 |
+
"type": "fear_amplification",
|
| 426 |
+
"label": "Fear induction"
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"phrase": "пока это не удалили",
|
| 430 |
+
"type": "false_urgency",
|
| 431 |
+
"label": "Urgency creation"
|
| 432 |
+
}
|
| 433 |
+
],
|
| 434 |
+
"bias_check": "The text was checked against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to avoid false positives.",
|
| 435 |
+
"citations": [
|
| 436 |
+
"https://www.ohchr.org/en/documents/general-comments-and-recommendations/general-comment-no34-article-19-freedoms-opinion-and",
|
| 437 |
+
"https://www.unesco.org/en/media-information-literacy",
|
| 438 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms"
|
| 439 |
+
],
|
| 440 |
+
"source": "llm",
|
| 441 |
+
"kind": "manipulation"
|
| 442 |
+
}
|
| 443 |
+
},
|
| 444 |
+
"p17": {
|
| 445 |
+
"classification": {
|
| 446 |
+
"label": "FLAGGED",
|
| 447 |
+
"flagged": true,
|
| 448 |
+
"kind": "hate",
|
| 449 |
+
"confidence": 0.9984,
|
| 450 |
+
"hate_confidence": 0.9984,
|
| 451 |
+
"manipulation_confidence": 0.8944,
|
| 452 |
+
"high_confidence": true,
|
| 453 |
+
"truncated": false,
|
| 454 |
+
"raw_scores": {
|
| 455 |
+
"not offensive": 0.0016,
|
| 456 |
+
"offensive": 0.9984
|
| 457 |
+
},
|
| 458 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 459 |
+
"verification": {
|
| 460 |
+
"relevant": true,
|
| 461 |
+
"reason": "The post contains clear dehumanizing language and identity-based degradation.",
|
| 462 |
+
"checked": true
|
| 463 |
+
}
|
| 464 |
+
},
|
| 465 |
+
"analysis": {
|
| 466 |
+
"techniques": [
|
| 467 |
+
"dehumanizing_language",
|
| 468 |
+
"identity_targeting",
|
| 469 |
+
"us_vs_them"
|
| 470 |
+
],
|
| 471 |
+
"emotional_triggers": [
|
| 472 |
+
"fear",
|
| 473 |
+
"disgust"
|
| 474 |
+
],
|
| 475 |
+
"explanation": "The post uses dehumanizing language and targets a group, implying they are a threat.",
|
| 476 |
+
"recommendation": "Read with caution, considering the potential for harmful stereotypes.",
|
| 477 |
+
"severity": "high",
|
| 478 |
+
"highlights": [
|
| 479 |
+
{
|
| 480 |
+
"phrase": "They're animals, plain and simple",
|
| 481 |
+
"type": "dehumanizing_language",
|
| 482 |
+
"label": "Dehumanization"
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
"phrase": "should be removed from our streets",
|
| 486 |
+
"type": "identity_targeting",
|
| 487 |
+
"label": "Targeting a group"
|
| 488 |
+
}
|
| 489 |
+
],
|
| 490 |
+
"bias_check": "The text was checked against Article 19 free-expression protections, Rabat's high threshold for incitement, and non-discrimination safeguards, but crossed the threshold due to clear dehumanization and targeting.",
|
| 491 |
+
"citations": [
|
| 492 |
+
"https://www.ohchr.org/en/documents/outcome-documents/rabat-plan-action",
|
| 493 |
+
"https://www.un.org/en/hate-speech/un-strategy-and-plan-of-action-on-hate-speech",
|
| 494 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms"
|
| 495 |
+
],
|
| 496 |
+
"source": "llm",
|
| 497 |
+
"kind": "hate"
|
| 498 |
+
}
|
| 499 |
+
},
|
| 500 |
+
"p5": {
|
| 501 |
+
"classification": {
|
| 502 |
+
"label": "CLEAR",
|
| 503 |
+
"flagged": false,
|
| 504 |
+
"kind": null,
|
| 505 |
+
"confidence": 0.6593,
|
| 506 |
+
"hate_confidence": 0.0007,
|
| 507 |
+
"manipulation_confidence": 0.6593,
|
| 508 |
+
"high_confidence": false,
|
| 509 |
+
"truncated": false,
|
| 510 |
+
"raw_scores": {
|
| 511 |
+
"not offensive": 0.9993,
|
| 512 |
+
"offensive": 0.0007
|
| 513 |
+
},
|
| 514 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 515 |
+
"verification": {
|
| 516 |
+
"relevant": true,
|
| 517 |
+
"reason": "The analysis accurately describes the post as lacking manipulative techniques.",
|
| 518 |
+
"checked": true
|
| 519 |
+
}
|
| 520 |
+
},
|
| 521 |
+
"analysis": null
|
| 522 |
+
},
|
| 523 |
+
"p12": {
|
| 524 |
+
"classification": {
|
| 525 |
+
"label": "FLAGGED",
|
| 526 |
+
"flagged": true,
|
| 527 |
+
"kind": "manipulation",
|
| 528 |
+
"confidence": 0.8624,
|
| 529 |
+
"hate_confidence": 0.0005,
|
| 530 |
+
"manipulation_confidence": 0.8624,
|
| 531 |
+
"high_confidence": true,
|
| 532 |
+
"truncated": false,
|
| 533 |
+
"raw_scores": {
|
| 534 |
+
"not offensive": 0.9995,
|
| 535 |
+
"offensive": 0.0005
|
| 536 |
+
},
|
| 537 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 538 |
+
"verification": {
|
| 539 |
+
"relevant": true,
|
| 540 |
+
"reason": "The analysis accurately identifies fear_amplification, false_urgency, and emotional_manipulation techniques in the post.",
|
| 541 |
+
"checked": true
|
| 542 |
+
}
|
| 543 |
+
},
|
| 544 |
+
"analysis": {
|
| 545 |
+
"techniques": [
|
| 546 |
+
"fear_amplification",
|
| 547 |
+
"false_urgency",
|
| 548 |
+
"emotional_manipulation"
|
| 549 |
+
],
|
| 550 |
+
"emotional_triggers": [
|
| 551 |
+
"fear",
|
| 552 |
+
"guilt"
|
| 553 |
+
],
|
| 554 |
+
"explanation": "The wording tries to influence the reader by creating fear and guilt about the curriculum's impact on children.",
|
| 555 |
+
"recommendation": "Verify the information through reputable sources before reacting or sharing.",
|
| 556 |
+
"severity": "medium",
|
| 557 |
+
"highlights": [
|
| 558 |
+
{
|
| 559 |
+
"phrase": "don't call yourself a real parent",
|
| 560 |
+
"type": "emotional_manipulation",
|
| 561 |
+
"label": "Guilt trip"
|
| 562 |
+
},
|
| 563 |
+
{
|
| 564 |
+
"phrase": "destroy your child's mind",
|
| 565 |
+
"type": "fear_amplification",
|
| 566 |
+
"label": "Fear escalation"
|
| 567 |
+
},
|
| 568 |
+
{
|
| 569 |
+
"phrase": "Act before it's too late",
|
| 570 |
+
"type": "false_urgency",
|
| 571 |
+
"label": "Urgency creation"
|
| 572 |
+
}
|
| 573 |
+
],
|
| 574 |
+
"bias_check": "I checked the text against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to avoid false positives.",
|
| 575 |
+
"citations": [
|
| 576 |
+
"https://www.ohchr.org/en/documents/general-comments-and-recommendations/general-comment-no34-article-19-freedoms-opinion-and",
|
| 577 |
+
"https://www.unesco.org/en/media-information-literacy",
|
| 578 |
+
"https://www.unesco.org/en/articles/guidelines-governance-digital-platforms"
|
| 579 |
+
],
|
| 580 |
+
"source": "llm",
|
| 581 |
+
"kind": "manipulation"
|
| 582 |
+
}
|
| 583 |
+
},
|
| 584 |
+
"p18": {
|
| 585 |
+
"classification": {
|
| 586 |
+
"label": "FLAGGED",
|
| 587 |
+
"flagged": true,
|
| 588 |
+
"kind": "manipulation",
|
| 589 |
+
"confidence": 0.7491,
|
| 590 |
+
"hate_confidence": 0.0225,
|
| 591 |
+
"manipulation_confidence": 0.7491,
|
| 592 |
+
"high_confidence": false,
|
| 593 |
+
"truncated": false,
|
| 594 |
+
"raw_scores": {
|
| 595 |
+
"not offensive": 0.9775,
|
| 596 |
+
"offensive": 0.0225
|
| 597 |
+
},
|
| 598 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 599 |
+
"verification": {
|
| 600 |
+
"relevant": true,
|
| 601 |
+
"reason": "The post contains dehumanizing language and scapegoating towards a group.",
|
| 602 |
+
"checked": true
|
| 603 |
+
}
|
| 604 |
+
},
|
| 605 |
+
"analysis": {
|
| 606 |
+
"techniques": [
|
| 607 |
+
"scapegoating",
|
| 608 |
+
"dehumanizing_language",
|
| 609 |
+
"us_vs_them"
|
| 610 |
+
],
|
| 611 |
+
"emotional_triggers": [
|
| 612 |
+
"fear",
|
| 613 |
+
"anger",
|
| 614 |
+
"disgust"
|
| 615 |
+
],
|
| 616 |
+
"explanation": "The wording uses dehumanizing language and scapegoating to create fear and anger towards a group.",
|
| 617 |
+
"recommendation": "Consider verifying information before reacting or sharing to avoid spreading harmful stereotypes.",
|
| 618 |
+
"severity": "high",
|
| 619 |
+
"highlights": [
|
| 620 |
+
{
|
| 621 |
+
"phrase": "They've infested every neighborhood they've touched and turned it into a dump",
|
| 622 |
+
"type": "dehumanizing_language",
|
| 623 |
+
"label": "Dehumanizing comparison"
|
| 624 |
+
},
|
| 625 |
+
{
|
| 626 |
+
"phrase": "Pack them up and send them back where they came from",
|
| 627 |
+
"type": "scapegoating",
|
| 628 |
+
"label": "Scapegoating and expulsion"
|
| 629 |
+
}
|
| 630 |
+
],
|
| 631 |
+
"bias_check": "I checked the text against Article 19 protections, satire, ordinary advocacy, cultural and linguistic nuance, and non-discrimination to ensure it does not unfairly target a group.",
|
| 632 |
+
"citations": [
|
| 633 |
+
"https://www.ohchr.org/en/documents/outcome-documents/rabat-plan-action",
|
| 634 |
+
"https://www.un.org/en/hate-speech/un-strategy-and-plan-of-action-on-hate-speech",
|
| 635 |
+
"https://www.unesco.org/en/media-information-literacy"
|
| 636 |
+
],
|
| 637 |
+
"source": "llm",
|
| 638 |
+
"kind": "manipulation"
|
| 639 |
+
}
|
| 640 |
+
},
|
| 641 |
+
"p7": {
|
| 642 |
+
"classification": {
|
| 643 |
+
"label": "CLEAR",
|
| 644 |
+
"flagged": false,
|
| 645 |
+
"kind": null,
|
| 646 |
+
"confidence": 0.6278,
|
| 647 |
+
"hate_confidence": 0.0002,
|
| 648 |
+
"manipulation_confidence": 0.6278,
|
| 649 |
+
"high_confidence": false,
|
| 650 |
+
"truncated": false,
|
| 651 |
+
"raw_scores": {
|
| 652 |
+
"not offensive": 0.9998,
|
| 653 |
+
"offensive": 0.0002
|
| 654 |
+
},
|
| 655 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 656 |
+
"verification": {
|
| 657 |
+
"relevant": false,
|
| 658 |
+
"reason": "Analysis incorrectly flags post as manipulation when it is ordinary opinion and reflection.",
|
| 659 |
+
"checked": true
|
| 660 |
+
}
|
| 661 |
+
},
|
| 662 |
+
"analysis": null
|
| 663 |
+
},
|
| 664 |
+
"p9": {
|
| 665 |
+
"classification": {
|
| 666 |
+
"label": "CLEAR",
|
| 667 |
+
"flagged": false,
|
| 668 |
+
"kind": null,
|
| 669 |
+
"confidence": 0.9008,
|
| 670 |
+
"hate_confidence": 0.0004,
|
| 671 |
+
"manipulation_confidence": 0.9008,
|
| 672 |
+
"high_confidence": false,
|
| 673 |
+
"truncated": false,
|
| 674 |
+
"raw_scores": {
|
| 675 |
+
"not offensive": 0.9996,
|
| 676 |
+
"offensive": 0.0004
|
| 677 |
+
},
|
| 678 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 679 |
+
"verification": {
|
| 680 |
+
"relevant": false,
|
| 681 |
+
"reason": "The analysis overreaches by labeling ordinary opinion and criticism of power as manipulation without clear textual evidence.",
|
| 682 |
+
"checked": true
|
| 683 |
+
}
|
| 684 |
+
},
|
| 685 |
+
"analysis": null
|
| 686 |
+
},
|
| 687 |
+
"p13": {
|
| 688 |
+
"classification": {
|
| 689 |
+
"label": "CLEAR",
|
| 690 |
+
"flagged": false,
|
| 691 |
+
"kind": null,
|
| 692 |
+
"confidence": 0.7975,
|
| 693 |
+
"hate_confidence": 0.0012,
|
| 694 |
+
"manipulation_confidence": 0.7975,
|
| 695 |
+
"high_confidence": false,
|
| 696 |
+
"truncated": false,
|
| 697 |
+
"raw_scores": {
|
| 698 |
+
"not offensive": 0.9988,
|
| 699 |
+
"offensive": 0.0012
|
| 700 |
+
},
|
| 701 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 702 |
+
"verification": {
|
| 703 |
+
"relevant": true,
|
| 704 |
+
"reason": "The analysis accurately identifies the post's use of sarcasm to express frustration.",
|
| 705 |
+
"checked": true
|
| 706 |
+
}
|
| 707 |
+
},
|
| 708 |
+
"analysis": null
|
| 709 |
+
},
|
| 710 |
+
"p4": {
|
| 711 |
+
"classification": {
|
| 712 |
+
"label": "CLEAR",
|
| 713 |
+
"flagged": false,
|
| 714 |
+
"kind": null,
|
| 715 |
+
"confidence": 0.3288,
|
| 716 |
+
"hate_confidence": 0.001,
|
| 717 |
+
"manipulation_confidence": 0.3288,
|
| 718 |
+
"high_confidence": false,
|
| 719 |
+
"truncated": false,
|
| 720 |
+
"raw_scores": {
|
| 721 |
+
"not offensive": 0.999,
|
| 722 |
+
"offensive": 0.001
|
| 723 |
+
},
|
| 724 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 725 |
+
"verification": null
|
| 726 |
+
},
|
| 727 |
+
"analysis": null
|
| 728 |
+
},
|
| 729 |
+
"p14": {
|
| 730 |
+
"classification": {
|
| 731 |
+
"label": "CLEAR",
|
| 732 |
+
"flagged": false,
|
| 733 |
+
"kind": null,
|
| 734 |
+
"confidence": 0.7382,
|
| 735 |
+
"hate_confidence": 0.0003,
|
| 736 |
+
"manipulation_confidence": 0.7382,
|
| 737 |
+
"high_confidence": false,
|
| 738 |
+
"truncated": false,
|
| 739 |
+
"raw_scores": {
|
| 740 |
+
"not offensive": 0.9997,
|
| 741 |
+
"offensive": 0.0003
|
| 742 |
+
},
|
| 743 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 744 |
+
"verification": {
|
| 745 |
+
"relevant": true,
|
| 746 |
+
"reason": "Analysis accurately describes post's cautious skepticism without overreaching.",
|
| 747 |
+
"checked": true
|
| 748 |
+
}
|
| 749 |
+
},
|
| 750 |
+
"analysis": null
|
| 751 |
+
},
|
| 752 |
+
"p6": {
|
| 753 |
+
"classification": {
|
| 754 |
+
"label": "CLEAR",
|
| 755 |
+
"flagged": false,
|
| 756 |
+
"kind": null,
|
| 757 |
+
"confidence": 0.0791,
|
| 758 |
+
"hate_confidence": 0.0054,
|
| 759 |
+
"manipulation_confidence": 0.0791,
|
| 760 |
+
"high_confidence": false,
|
| 761 |
+
"truncated": false,
|
| 762 |
+
"raw_scores": {
|
| 763 |
+
"not offensive": 0.9946,
|
| 764 |
+
"offensive": 0.0054
|
| 765 |
+
},
|
| 766 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 767 |
+
"verification": null
|
| 768 |
+
},
|
| 769 |
+
"analysis": null
|
| 770 |
+
},
|
| 771 |
+
"p19": {
|
| 772 |
+
"classification": {
|
| 773 |
+
"label": "CLEAR",
|
| 774 |
+
"flagged": false,
|
| 775 |
+
"kind": null,
|
| 776 |
+
"confidence": 0.7153,
|
| 777 |
+
"hate_confidence": 0.0009,
|
| 778 |
+
"manipulation_confidence": 0.7153,
|
| 779 |
+
"high_confidence": false,
|
| 780 |
+
"truncated": false,
|
| 781 |
+
"raw_scores": {
|
| 782 |
+
"not offensive": 0.9991,
|
| 783 |
+
"offensive": 0.0009
|
| 784 |
+
},
|
| 785 |
+
"model": "Andrazp/multilingual-hate-speech-robacofi",
|
| 786 |
+
"verification": {
|
| 787 |
+
"relevant": false,
|
| 788 |
+
"reason": "The analysis overreaches by labeling ordinary opinion as scapegoating without clear textual evidence of identity-based degradation or dehumanization.",
|
| 789 |
+
"checked": true
|
| 790 |
+
}
|
| 791 |
+
},
|
| 792 |
+
"analysis": null
|
| 793 |
+
}
|
| 794 |
+
}
|
| 795 |
+
}
|
backend/data/posts.json
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "p1",
|
| 4 |
+
"user": {
|
| 5 |
+
"name": "Maya Chen",
|
| 6 |
+
"handle": "maya_codes",
|
| 7 |
+
"initial": "M",
|
| 8 |
+
"color": "#2563EB",
|
| 9 |
+
"verified": false
|
| 10 |
+
},
|
| 11 |
+
"time": "12m",
|
| 12 |
+
"text": "spent three hours debugging today only to realize i'd been editing the wrong file the entire time. anyway. how is everyone's monday going",
|
| 13 |
+
"media": null,
|
| 14 |
+
"engagement": {
|
| 15 |
+
"replies": 156,
|
| 16 |
+
"reposts": 89,
|
| 17 |
+
"likes": 1243
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"id": "p8",
|
| 22 |
+
"user": {
|
| 23 |
+
"name": "Truth Hour",
|
| 24 |
+
"handle": "TheRealTruthHour",
|
| 25 |
+
"initial": "T",
|
| 26 |
+
"color": "#DC2626",
|
| 27 |
+
"verified": false
|
| 28 |
+
},
|
| 29 |
+
"time": "26m",
|
| 30 |
+
"text": "They don't want you to know this, but the new digital ID rolling out next month means they can switch off your bank account with a single click. This is NOT a conspiracy. Screenshot this before it gets taken down. ⏰",
|
| 31 |
+
"media": null,
|
| 32 |
+
"engagement": {
|
| 33 |
+
"replies": 3400,
|
| 34 |
+
"reposts": 8900,
|
| 35 |
+
"likes": 12000
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "v1",
|
| 40 |
+
"user": {
|
| 41 |
+
"name": "Truth Unfiltered",
|
| 42 |
+
"handle": "truth_unfiltered",
|
| 43 |
+
"initial": "T",
|
| 44 |
+
"color": "#B91C1C",
|
| 45 |
+
"verified": false
|
| 46 |
+
},
|
| 47 |
+
"time": "22m",
|
| 48 |
+
"text": "🚨 They're not showing you THIS on the news. Watch before they take it down — this is what they don't want you to see. Share it NOW while you still can, before it's too late.",
|
| 49 |
+
"media": {
|
| 50 |
+
"type": "video",
|
| 51 |
+
"src": "/media/video.mp4"
|
| 52 |
+
},
|
| 53 |
+
"engagement": {
|
| 54 |
+
"replies": 2100,
|
| 55 |
+
"reposts": 7400,
|
| 56 |
+
"likes": 9800
|
| 57 |
+
}
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "p16",
|
| 61 |
+
"user": {
|
| 62 |
+
"name": "ZephyrX",
|
| 63 |
+
"handle": "ZephyrX_",
|
| 64 |
+
"initial": "Z",
|
| 65 |
+
"color": "#1F2937",
|
| 66 |
+
"verified": false
|
| 67 |
+
},
|
| 68 |
+
"time": "6h",
|
| 69 |
+
"text": "Every one of them is the same — vermin who swarm in and bleed this place dry. They don't belong here and they should be driven out before there's nothing left.",
|
| 70 |
+
"media": null,
|
| 71 |
+
"engagement": {
|
| 72 |
+
"replies": 890,
|
| 73 |
+
"reposts": 1100,
|
| 74 |
+
"likes": 2300
|
| 75 |
+
}
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "p3",
|
| 79 |
+
"user": {
|
| 80 |
+
"name": "Priya K.",
|
| 81 |
+
"handle": "aerialnomad",
|
| 82 |
+
"initial": "P",
|
| 83 |
+
"color": "#0D9488",
|
| 84 |
+
"verified": true
|
| 85 |
+
},
|
| 86 |
+
"time": "1h",
|
| 87 |
+
"text": "Sunrise over the valley in Patagonia right now. Some places just don't feel real until you're standing in them. 🏔️",
|
| 88 |
+
"media": {
|
| 89 |
+
"type": "gradient"
|
| 90 |
+
},
|
| 91 |
+
"engagement": {
|
| 92 |
+
"replies": 230,
|
| 93 |
+
"reposts": 1200,
|
| 94 |
+
"likes": 8900
|
| 95 |
+
}
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "p10",
|
| 99 |
+
"user": {
|
| 100 |
+
"name": "Brad // crypto",
|
| 101 |
+
"handle": "cryptobrad",
|
| 102 |
+
"initial": "B",
|
| 103 |
+
"color": "#F59E0B",
|
| 104 |
+
"verified": false
|
| 105 |
+
},
|
| 106 |
+
"time": "1h",
|
| 107 |
+
"text": "🚨 ONLY 100 spots left. This is your LAST chance to get in before the price 10x's. The people who hesitate will be kicking themselves in 6 months. DM me NOW or stay broke. 👉 https://t.co/x9f2aQ",
|
| 108 |
+
"media": null,
|
| 109 |
+
"engagement": {
|
| 110 |
+
"replies": 1200,
|
| 111 |
+
"reposts": 450,
|
| 112 |
+
"likes": 890
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "p15",
|
| 117 |
+
"user": {
|
| 118 |
+
"name": "Mike",
|
| 119 |
+
"handle": "patriot_mike49",
|
| 120 |
+
"initial": "M",
|
| 121 |
+
"color": "#991B1B",
|
| 122 |
+
"verified": false
|
| 123 |
+
},
|
| 124 |
+
"time": "5h",
|
| 125 |
+
"text": "These people pour into our town, don't bother learning how we live, and expect us to bend over backwards for them. They breed faster than we can keep up and soon there'll be nothing left of who we are. Enough.",
|
| 126 |
+
"media": null,
|
| 127 |
+
"engagement": {
|
| 128 |
+
"replies": 1200,
|
| 129 |
+
"reposts": 3400,
|
| 130 |
+
"likes": 5600
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"id": "p2",
|
| 135 |
+
"user": {
|
| 136 |
+
"name": "Dave R.",
|
| 137 |
+
"handle": "grillmaster_dave",
|
| 138 |
+
"initial": "D",
|
| 139 |
+
"color": "#D97706",
|
| 140 |
+
"verified": false
|
| 141 |
+
},
|
| 142 |
+
"time": "34m",
|
| 143 |
+
"text": "Unpopular opinion: pineapple absolutely belongs on pizza and I'm tired of pretending it doesn't 🍍",
|
| 144 |
+
"media": null,
|
| 145 |
+
"engagement": {
|
| 146 |
+
"replies": 890,
|
| 147 |
+
"reposts": 320,
|
| 148 |
+
"likes": 4500
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"id": "p11",
|
| 153 |
+
"user": {
|
| 154 |
+
"name": "Real Talk Radio",
|
| 155 |
+
"handle": "realtalkradio",
|
| 156 |
+
"initial": "R",
|
| 157 |
+
"color": "#B91C1C",
|
| 158 |
+
"verified": false
|
| 159 |
+
},
|
| 160 |
+
"time": "2h",
|
| 161 |
+
"text": "Notice how every time regular people start asking real questions, there's suddenly a brand new crisis to distract everyone? That's not a coincidence. They've been running this exact playbook for decades.",
|
| 162 |
+
"media": null,
|
| 163 |
+
"engagement": {
|
| 164 |
+
"replies": 780,
|
| 165 |
+
"reposts": 5600,
|
| 166 |
+
"likes": 9800
|
| 167 |
+
}
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"id": "ru1",
|
| 171 |
+
"user": {
|
| 172 |
+
"name": "Дмитрий",
|
| 173 |
+
"handle": "dmitry_news",
|
| 174 |
+
"initial": "Д",
|
| 175 |
+
"color": "#1D4ED8",
|
| 176 |
+
"verified": false
|
| 177 |
+
},
|
| 178 |
+
"time": "40m",
|
| 179 |
+
"text": "Они не хотят, чтобы вы это знали: новый закон позволит им отключить ваш счёт одним щелчком. Не молчите. Сделайте репост, пока это не удалили.",
|
| 180 |
+
"media": null,
|
| 181 |
+
"engagement": {
|
| 182 |
+
"replies": 980,
|
| 183 |
+
"reposts": 5200,
|
| 184 |
+
"likes": 4100
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"id": "p17",
|
| 189 |
+
"user": {
|
| 190 |
+
"name": "Borderwatch",
|
| 191 |
+
"handle": "borderwatch_now",
|
| 192 |
+
"initial": "B",
|
| 193 |
+
"color": "#7F1D1D",
|
| 194 |
+
"verified": false
|
| 195 |
+
},
|
| 196 |
+
"time": "7h",
|
| 197 |
+
"text": "They're animals, plain and simple, and you can't civilize an animal. People like that should be removed from our streets before they ruin everything.",
|
| 198 |
+
"media": null,
|
| 199 |
+
"engagement": {
|
| 200 |
+
"replies": 1500,
|
| 201 |
+
"reposts": 2100,
|
| 202 |
+
"likes": 3400
|
| 203 |
+
}
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "p5",
|
| 207 |
+
"user": {
|
| 208 |
+
"name": "Sam · RN",
|
| 209 |
+
"handle": "nightshift_sam",
|
| 210 |
+
"initial": "S",
|
| 211 |
+
"color": "#7C3AED",
|
| 212 |
+
"verified": false
|
| 213 |
+
},
|
| 214 |
+
"time": "3h",
|
| 215 |
+
"text": "12 hour shift done. feet wrecked, heart full. a patient's family brought the whole unit donuts today and i nearly cried in the break room. it's the small stuff.",
|
| 216 |
+
"media": null,
|
| 217 |
+
"engagement": {
|
| 218 |
+
"replies": 145,
|
| 219 |
+
"reposts": 210,
|
| 220 |
+
"likes": 3400
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"id": "p12",
|
| 225 |
+
"user": {
|
| 226 |
+
"name": "Concerned Parent",
|
| 227 |
+
"handle": "concerned_parent_99",
|
| 228 |
+
"initial": "C",
|
| 229 |
+
"color": "#92400E",
|
| 230 |
+
"verified": false
|
| 231 |
+
},
|
| 232 |
+
"time": "3h",
|
| 233 |
+
"text": "If you don't share this, don't call yourself a real parent. They are putting things into the curriculum that will destroy your child's mind by next year. Act before it's too late.",
|
| 234 |
+
"media": null,
|
| 235 |
+
"engagement": {
|
| 236 |
+
"replies": 2300,
|
| 237 |
+
"reposts": 6700,
|
| 238 |
+
"likes": 4500
|
| 239 |
+
}
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"id": "p18",
|
| 243 |
+
"user": {
|
| 244 |
+
"name": "Old Town Voice",
|
| 245 |
+
"handle": "oldtownvoice",
|
| 246 |
+
"initial": "O",
|
| 247 |
+
"color": "#78350F",
|
| 248 |
+
"verified": false
|
| 249 |
+
},
|
| 250 |
+
"time": "9h",
|
| 251 |
+
"text": "They've infested every neighborhood they've touched and turned it into a dump. Pack them up and send them back where they came from before there's nothing left of this place.",
|
| 252 |
+
"media": null,
|
| 253 |
+
"engagement": {
|
| 254 |
+
"replies": 1900,
|
| 255 |
+
"reposts": 2800,
|
| 256 |
+
"likes": 4500
|
| 257 |
+
}
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"id": "p7",
|
| 261 |
+
"user": {
|
| 262 |
+
"name": "Anna Whitfield",
|
| 263 |
+
"handle": "historybuff_anna",
|
| 264 |
+
"initial": "A",
|
| 265 |
+
"color": "#9333EA",
|
| 266 |
+
"verified": true
|
| 267 |
+
},
|
| 268 |
+
"time": "5h",
|
| 269 |
+
"text": "people ask why i love history and honestly it's because nothing is ever as simple as the textbook makes it sound. every 'obvious' decision was made by a real person who couldn't see the ending, who was scared and tired and working with bad information. we read the past like the outcome was inevitable, but the people living it had no idea which way it would go. that's the part that stays with me, not the dates or the battles but the uncertainty. it makes me a lot gentler about the present, knowing future generations will flatten us into a tidy paragraph too.",
|
| 270 |
+
"media": null,
|
| 271 |
+
"engagement": {
|
| 272 |
+
"replies": 210,
|
| 273 |
+
"reposts": 980,
|
| 274 |
+
"likes": 5600
|
| 275 |
+
}
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"id": "p9",
|
| 279 |
+
"user": {
|
| 280 |
+
"name": "Open Your Eyes",
|
| 281 |
+
"handle": "wakeup_sheeple",
|
| 282 |
+
"initial": "O",
|
| 283 |
+
"color": "#7C2D12",
|
| 284 |
+
"verified": false
|
| 285 |
+
},
|
| 286 |
+
"time": "1h",
|
| 287 |
+
"text": "Wake up. Everything you were taught was designed to keep you obedient and quiet. The people at the top are laughing at how easy it was. Stop trusting them and start trusting your gut.",
|
| 288 |
+
"media": null,
|
| 289 |
+
"engagement": {
|
| 290 |
+
"replies": 890,
|
| 291 |
+
"reposts": 4200,
|
| 292 |
+
"likes": 6700
|
| 293 |
+
}
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"id": "p13",
|
| 297 |
+
"user": {
|
| 298 |
+
"name": "doomscroller",
|
| 299 |
+
"handle": "doomscroller",
|
| 300 |
+
"initial": "d",
|
| 301 |
+
"color": "#475569",
|
| 302 |
+
"verified": false
|
| 303 |
+
},
|
| 304 |
+
"time": "3h",
|
| 305 |
+
"text": "oh good, another 'once in a lifetime' crisis this week. truly we are so blessed to live through the single most important moment in human history every monday 🙄",
|
| 306 |
+
"media": null,
|
| 307 |
+
"engagement": {
|
| 308 |
+
"replies": 120,
|
| 309 |
+
"reposts": 670,
|
| 310 |
+
"likes": 3200
|
| 311 |
+
}
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"id": "p4",
|
| 315 |
+
"user": {
|
| 316 |
+
"name": "Lena Ortiz",
|
| 317 |
+
"handle": "lena_writes",
|
| 318 |
+
"initial": "L",
|
| 319 |
+
"color": "#DB2777",
|
| 320 |
+
"verified": false
|
| 321 |
+
},
|
| 322 |
+
"time": "2h",
|
| 323 |
+
"text": "finished the first draft of my novel at 2am and immediately started doubting every single word of it. writing is just professionally arguing with yourself.",
|
| 324 |
+
"media": null,
|
| 325 |
+
"engagement": {
|
| 326 |
+
"replies": 98,
|
| 327 |
+
"reposts": 340,
|
| 328 |
+
"likes": 2100
|
| 329 |
+
}
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"id": "p14",
|
| 333 |
+
"user": {
|
| 334 |
+
"name": "just asking",
|
| 335 |
+
"handle": "quiet_observer",
|
| 336 |
+
"initial": "j",
|
| 337 |
+
"color": "#64748B",
|
| 338 |
+
"verified": false
|
| 339 |
+
},
|
| 340 |
+
"time": "4h",
|
| 341 |
+
"text": "i'm not saying anything is definitely wrong with the new policy... but doesn't it feel a little convenient? a lot of people are quietly starting to wonder, and maybe that's worth paying attention to.",
|
| 342 |
+
"media": null,
|
| 343 |
+
"engagement": {
|
| 344 |
+
"replies": 200,
|
| 345 |
+
"reposts": 340,
|
| 346 |
+
"likes": 1200
|
| 347 |
+
}
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"id": "p6",
|
| 351 |
+
"user": {
|
| 352 |
+
"name": "Tom B.",
|
| 353 |
+
"handle": "tom_b",
|
| 354 |
+
"initial": "T",
|
| 355 |
+
"color": "#0F172A",
|
| 356 |
+
"verified": false
|
| 357 |
+
},
|
| 358 |
+
"time": "4h",
|
| 359 |
+
"text": "is it coffee o'clock yet",
|
| 360 |
+
"media": null,
|
| 361 |
+
"engagement": {
|
| 362 |
+
"replies": 34,
|
| 363 |
+
"reposts": 12,
|
| 364 |
+
"likes": 540
|
| 365 |
+
}
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"id": "p19",
|
| 369 |
+
"user": {
|
| 370 |
+
"name": "Jane",
|
| 371 |
+
"handle": "realtalk_jane",
|
| 372 |
+
"initial": "J",
|
| 373 |
+
"color": "#a16207",
|
| 374 |
+
"verified": false
|
| 375 |
+
},
|
| 376 |
+
"time": "11h",
|
| 377 |
+
"text": "funny how a certain kind of people always seem to end up at the center of every single problem in this city. at some point you stop calling it a coincidence.",
|
| 378 |
+
"media": null,
|
| 379 |
+
"engagement": {
|
| 380 |
+
"replies": 600,
|
| 381 |
+
"reposts": 900,
|
| 382 |
+
"likes": 1800
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
]
|
backend/data/video.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:33f3b1a23301f4340f1bd896510563403650931899e6def3bc165af413d07075
|
| 3 |
+
size 3607350
|
backend/llm.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider-agnostic JSON completion for InfoShield.
|
| 2 |
+
|
| 3 |
+
`complete_json(system, user, provider=None, model=None)` -> parsed dict (or None).
|
| 4 |
+
Provider defaults to `config.LLM_PROVIDER` ("groq" now, "gemini" later) but can be
|
| 5 |
+
overridden per call -- the verifier uses this to (later) run on a different/best
|
| 6 |
+
model than the analysis. Heavy SDK imports are deferred; missing key/SDK -> None
|
| 7 |
+
so callers fall back gracefully.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import re
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
from config import (
|
| 17 |
+
GEMINI_API_KEY,
|
| 18 |
+
GEMINI_MODEL,
|
| 19 |
+
GROQ_API_KEY,
|
| 20 |
+
GROQ_MODEL,
|
| 21 |
+
LLM_PROVIDER,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
log = logging.getLogger("infoshield.llm")
|
| 25 |
+
|
| 26 |
+
_clients: dict[str, object] = {} # provider -> client (or None once tried & failed)
|
| 27 |
+
_tried: set[str] = set()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _coerce_json(text: str) -> Optional[dict]:
|
| 31 |
+
"""Best-effort: parse a JSON object, tolerating stray markdown fences."""
|
| 32 |
+
if not text:
|
| 33 |
+
return None
|
| 34 |
+
text = text.strip()
|
| 35 |
+
text = re.sub(r"^```(?:json)?", "", text).strip()
|
| 36 |
+
text = re.sub(r"```$", "", text).strip()
|
| 37 |
+
try:
|
| 38 |
+
return json.loads(text)
|
| 39 |
+
except json.JSONDecodeError:
|
| 40 |
+
m = re.search(r"\{.*\}", text, re.DOTALL)
|
| 41 |
+
if m:
|
| 42 |
+
try:
|
| 43 |
+
return json.loads(m.group(0))
|
| 44 |
+
except json.JSONDecodeError:
|
| 45 |
+
return None
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _has_key(provider: str) -> bool:
|
| 50 |
+
return bool(GEMINI_API_KEY) if provider == "gemini" else bool(GROQ_API_KEY)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def available(provider: Optional[str] = None) -> bool:
|
| 54 |
+
"""True if the (default or given) provider has a key configured."""
|
| 55 |
+
return _has_key(provider or LLM_PROVIDER)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _get_client(provider: str):
|
| 59 |
+
if provider in _clients:
|
| 60 |
+
return _clients[provider]
|
| 61 |
+
_tried.add(provider)
|
| 62 |
+
client = None
|
| 63 |
+
try:
|
| 64 |
+
if provider == "gemini":
|
| 65 |
+
if GEMINI_API_KEY:
|
| 66 |
+
from google import genai # deferred
|
| 67 |
+
|
| 68 |
+
client = genai.Client(api_key=GEMINI_API_KEY)
|
| 69 |
+
log.info("Gemini client initialized.")
|
| 70 |
+
else:
|
| 71 |
+
log.warning("GEMINI_API_KEY not set -- gemini calls unavailable.")
|
| 72 |
+
else: # groq
|
| 73 |
+
if GROQ_API_KEY:
|
| 74 |
+
from groq import Groq # deferred
|
| 75 |
+
|
| 76 |
+
client = Groq(api_key=GROQ_API_KEY)
|
| 77 |
+
log.info("Groq client initialized.")
|
| 78 |
+
else:
|
| 79 |
+
log.warning("GROQ_API_KEY not set -- groq calls unavailable.")
|
| 80 |
+
except Exception as exc: # noqa: BLE001
|
| 81 |
+
log.warning("Could not initialize %s client: %s", provider, exc)
|
| 82 |
+
client = None
|
| 83 |
+
_clients[provider] = client
|
| 84 |
+
return client
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# --- completion -----------------------------------------------------------
|
| 88 |
+
def complete_json(
|
| 89 |
+
system: str,
|
| 90 |
+
user: str,
|
| 91 |
+
provider: Optional[str] = None,
|
| 92 |
+
model: Optional[str] = None,
|
| 93 |
+
schema: Optional[dict] = None,
|
| 94 |
+
temperature: float = 0.2,
|
| 95 |
+
) -> Optional[dict]:
|
| 96 |
+
"""Return a parsed JSON object from the chosen provider, or None on failure.
|
| 97 |
+
|
| 98 |
+
`schema` is only used by Gemini (structured output); Groq uses JSON-object
|
| 99 |
+
mode and relies on the prompt + downstream validation.
|
| 100 |
+
"""
|
| 101 |
+
provider = (provider or LLM_PROVIDER).lower()
|
| 102 |
+
result = _complete_one(provider, system, user, model, schema, temperature)
|
| 103 |
+
if result is not None:
|
| 104 |
+
return result
|
| 105 |
+
# Resilience: if the chosen provider fails (e.g. Gemini out of credits), fall
|
| 106 |
+
# back to the other provider using its default model so the demo keeps working.
|
| 107 |
+
other = "groq" if provider == "gemini" else "gemini"
|
| 108 |
+
if _has_key(other):
|
| 109 |
+
log.info("Provider '%s' failed; falling back to '%s'.", provider, other)
|
| 110 |
+
return _complete_one(other, system, user, None, schema, temperature)
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _complete_one(provider, system, user, model, schema, temperature):
|
| 115 |
+
client = _get_client(provider)
|
| 116 |
+
if client is None:
|
| 117 |
+
return None
|
| 118 |
+
if provider == "gemini":
|
| 119 |
+
return _gemini_complete(client, system, user, model or GEMINI_MODEL, schema, temperature)
|
| 120 |
+
return _groq_complete(client, system, user, model or GROQ_MODEL, temperature)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _groq_complete(client, system: str, user: str, model: str, temperature: float) -> Optional[dict]:
|
| 124 |
+
try:
|
| 125 |
+
resp = client.chat.completions.create(
|
| 126 |
+
model=model,
|
| 127 |
+
messages=[
|
| 128 |
+
{"role": "system", "content": system},
|
| 129 |
+
{"role": "user", "content": user},
|
| 130 |
+
],
|
| 131 |
+
response_format={"type": "json_object"},
|
| 132 |
+
temperature=temperature,
|
| 133 |
+
)
|
| 134 |
+
return _coerce_json(resp.choices[0].message.content or "")
|
| 135 |
+
except Exception as exc: # noqa: BLE001
|
| 136 |
+
log.warning("Groq call failed: %s", exc)
|
| 137 |
+
return None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _gemini_complete(client, system: str, user: str, model: str, schema: Optional[dict], temperature: float) -> Optional[dict]:
|
| 141 |
+
try:
|
| 142 |
+
from google.genai import types # deferred
|
| 143 |
+
|
| 144 |
+
cfg = types.GenerateContentConfig(
|
| 145 |
+
system_instruction=system,
|
| 146 |
+
response_mime_type="application/json",
|
| 147 |
+
temperature=temperature,
|
| 148 |
+
)
|
| 149 |
+
if schema:
|
| 150 |
+
cfg.response_schema = schema
|
| 151 |
+
resp = client.models.generate_content(model=model, contents=user, config=cfg)
|
| 152 |
+
return _coerce_json(getattr(resp, "text", "") or "")
|
| 153 |
+
except Exception as exc: # noqa: BLE001
|
| 154 |
+
log.warning("Gemini call failed: %s", exc)
|
| 155 |
+
return None
|
backend/manip_classifier.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# PRIVACY BY DESIGN -- InfoShield local manipulation classifier
|
| 3 |
+
# -----------------------------------------------------------------------------
|
| 4 |
+
# Content-only and author-agnostic, exactly like the hate classifier: the only
|
| 5 |
+
# input is the post text. All inference runs locally.
|
| 6 |
+
# =============================================================================
|
| 7 |
+
"""Local manipulation / propaganda detector (the manipulation GATE).
|
| 8 |
+
|
| 9 |
+
Mirrors classifier.py but for manipulation. The primary checkpoint is a
|
| 10 |
+
multi-label (sigmoid) propaganda-technique model -- the manipulation score is the
|
| 11 |
+
strongest single technique probability. The fallback is a binary propaganda model
|
| 12 |
+
(softmax over [not, propaganda]). Heavy ML imports are deferred.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import re
|
| 18 |
+
import time
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from config import MANIP_FALLBACK_MODEL, MANIP_MODEL, MANIP_THRESHOLD, MAX_TOKENS
|
| 22 |
+
|
| 23 |
+
log = logging.getLogger("infoshield.manip")
|
| 24 |
+
|
| 25 |
+
_POS_SUBSTR = ("prop", "manip", "persuas", "label_") # "label_n" => generic multi-label head
|
| 26 |
+
_NEG_TOKENS = {"no", "not", "non", "none", "clean", "neutral", "normal", "negative"}
|
| 27 |
+
|
| 28 |
+
# Multi-label persuasion models include "negative" and structural taxonomy nodes that
|
| 29 |
+
# fire on almost any text (a root/parent category). We exclude these so the manipulation
|
| 30 |
+
# score is the strongest *concrete technique* probability, not a structural node.
|
| 31 |
+
_STRUCTURAL_LABELS = {
|
| 32 |
+
"root", "none", "no technique", "no_propaganda", "not offensive",
|
| 33 |
+
"logos", "ethos", "pathos", "reasoning", "justification",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ManipulationClassifier:
|
| 38 |
+
"""Lazy, device-aware wrapper. Returns a manipulation probability in [0, 1]."""
|
| 39 |
+
|
| 40 |
+
def __init__(self) -> None:
|
| 41 |
+
self._tokenizer = None
|
| 42 |
+
self._model = None
|
| 43 |
+
self._multilabel = False
|
| 44 |
+
self._pos_index: Optional[int] = None
|
| 45 |
+
self._keep_idx: Optional[list[int]] = None # technique labels to score over
|
| 46 |
+
self._max_len = MAX_TOKENS
|
| 47 |
+
self.model_name: Optional[str] = None
|
| 48 |
+
self.device: str = "cpu"
|
| 49 |
+
self.warmup_ms: Optional[float] = None
|
| 50 |
+
self.loaded: bool = False
|
| 51 |
+
|
| 52 |
+
def load(self) -> None:
|
| 53 |
+
if self.loaded:
|
| 54 |
+
return
|
| 55 |
+
import torch # deferred
|
| 56 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 57 |
+
|
| 58 |
+
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 59 |
+
|
| 60 |
+
last_err: Optional[Exception] = None
|
| 61 |
+
for name in (MANIP_MODEL, MANIP_FALLBACK_MODEL):
|
| 62 |
+
try:
|
| 63 |
+
log.info("Loading manipulation model '%s' ...", name)
|
| 64 |
+
self._tokenizer = AutoTokenizer.from_pretrained(name)
|
| 65 |
+
self._model = AutoModelForSequenceClassification.from_pretrained(name)
|
| 66 |
+
self._model.to(self.device)
|
| 67 |
+
self._model.eval()
|
| 68 |
+
self.model_name = name
|
| 69 |
+
self._resolve_head()
|
| 70 |
+
break
|
| 71 |
+
except Exception as exc: # noqa: BLE001 - try fallback
|
| 72 |
+
log.warning("Failed to load '%s': %s", name, exc)
|
| 73 |
+
last_err = exc
|
| 74 |
+
self._model = None
|
| 75 |
+
if self._model is None:
|
| 76 |
+
raise RuntimeError(f"Could not load any manipulation model: {last_err}")
|
| 77 |
+
|
| 78 |
+
self._warmup()
|
| 79 |
+
self.loaded = True
|
| 80 |
+
|
| 81 |
+
def _resolve_head(self) -> None:
|
| 82 |
+
cfg = self._model.config
|
| 83 |
+
n = getattr(cfg, "num_labels", 2) or 2
|
| 84 |
+
problem = (getattr(cfg, "problem_type", "") or "").lower()
|
| 85 |
+
# Respect the model's real context window (BERTweet is only 130 positions,
|
| 86 |
+
# not 512) to avoid position-embedding index errors on long posts.
|
| 87 |
+
mpe = getattr(cfg, "max_position_embeddings", MAX_TOKENS + 2) or (MAX_TOKENS + 2)
|
| 88 |
+
self._max_len = min(MAX_TOKENS, max(16, mpe - 2))
|
| 89 |
+
self._multilabel = problem == "multi_label_classification" or n > 2
|
| 90 |
+
if self._multilabel:
|
| 91 |
+
id2label = {int(k): str(v).lower().strip() for k, v in cfg.id2label.items()}
|
| 92 |
+
self._keep_idx = [i for i, lab in id2label.items() if lab not in _STRUCTURAL_LABELS]
|
| 93 |
+
if not self._keep_idx:
|
| 94 |
+
self._keep_idx = list(id2label.keys())
|
| 95 |
+
log.info("Manipulation head: multi-label (%d labels, %d technique labels scored), "
|
| 96 |
+
"score = max sigmoid over techniques.", n, len(self._keep_idx))
|
| 97 |
+
return
|
| 98 |
+
# Binary: find the "propaganda/manipulation present" index.
|
| 99 |
+
id2label = {int(k): str(v).lower() for k, v in cfg.id2label.items()}
|
| 100 |
+
for idx, label in id2label.items():
|
| 101 |
+
tokens = set(re.split(r"[\s_\-]+", label))
|
| 102 |
+
is_pos = any(s in label for s in _POS_SUBSTR) and not (tokens & _NEG_TOKENS)
|
| 103 |
+
if is_pos:
|
| 104 |
+
self._pos_index = idx
|
| 105 |
+
break
|
| 106 |
+
if self._pos_index is None:
|
| 107 |
+
self._pos_index = 1 if n == 2 else max(id2label)
|
| 108 |
+
log.info("Manipulation head: binary, positive index %d (%s).",
|
| 109 |
+
self._pos_index, id2label)
|
| 110 |
+
|
| 111 |
+
def _probability(self, text: str) -> tuple[float, bool]:
|
| 112 |
+
import torch
|
| 113 |
+
|
| 114 |
+
enc = self._tokenizer(text, return_tensors="pt", truncation=True, max_length=self._max_len)
|
| 115 |
+
truncated = bool(enc["input_ids"].shape[1] >= self._max_len)
|
| 116 |
+
enc = {k: v.to(self.device) for k, v in enc.items() if k in ("input_ids", "attention_mask")}
|
| 117 |
+
with torch.no_grad():
|
| 118 |
+
logits = self._model(**enc).logits[0]
|
| 119 |
+
if self._multilabel:
|
| 120 |
+
sig = torch.sigmoid(logits)
|
| 121 |
+
score = float(sig[self._keep_idx].max().item()) if self._keep_idx else float(sig.max().item())
|
| 122 |
+
else:
|
| 123 |
+
score = float(torch.softmax(logits, dim=-1)[self._pos_index].item())
|
| 124 |
+
return score, truncated
|
| 125 |
+
|
| 126 |
+
def classify(self, text: str) -> dict:
|
| 127 |
+
if not text or not text.strip():
|
| 128 |
+
return {"label": "CLEAR", "flagged": False, "confidence": 0.0,
|
| 129 |
+
"truncated": False, "model": self.model_name}
|
| 130 |
+
if not self.loaded:
|
| 131 |
+
self.load()
|
| 132 |
+
prob, truncated = self._probability(text)
|
| 133 |
+
flagged = prob >= MANIP_THRESHOLD
|
| 134 |
+
return {
|
| 135 |
+
"label": "MANIPULATION" if flagged else "CLEAR",
|
| 136 |
+
"flagged": flagged,
|
| 137 |
+
"confidence": round(prob, 4),
|
| 138 |
+
"truncated": truncated,
|
| 139 |
+
"model": self.model_name,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
def _warmup(self) -> None:
|
| 143 |
+
start = time.perf_counter()
|
| 144 |
+
self._probability("This is a warmup inference for InfoShield.")
|
| 145 |
+
self.warmup_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 146 |
+
log.info("Manipulation warmup on %s took ~%.1f ms", self.device, self.warmup_ms)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
_manip: Optional[ManipulationClassifier] = None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def get_manip_classifier() -> ManipulationClassifier:
|
| 153 |
+
global _manip
|
| 154 |
+
if _manip is None:
|
| 155 |
+
_manip = ManipulationClassifier()
|
| 156 |
+
return _manip
|
backend/pipeline.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-post analysis pipeline -- the single source of truth for serving.
|
| 2 |
+
|
| 3 |
+
For each post text:
|
| 4 |
+
1. HATE gate -- local RoBERTa classifier -> hate_confidence
|
| 5 |
+
2. MANIPULATION gate -- local propaganda classifier -> manipulation_confidence
|
| 6 |
+
3. If either fires -> LLM ANALYSIS (hate or manipulation prompt) explains WHY.
|
| 7 |
+
4. VERIFIER (2nd LLM) judges the analysis; if it's not relevant/accurate the
|
| 8 |
+
flag is overturned (treated as a false positive).
|
| 9 |
+
|
| 10 |
+
Both gates are local (fast, free), so the LLM only runs on flagged posts. Results
|
| 11 |
+
are cached by the caller (app.py) so the demo serves saved analysis instantly.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import threading
|
| 17 |
+
|
| 18 |
+
from config import HIGH_CONFIDENCE
|
| 19 |
+
|
| 20 |
+
log = logging.getLogger("infoshield.pipeline")
|
| 21 |
+
|
| 22 |
+
_clf_lock = threading.Lock() # serialize the (fast) local models across threads
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def analyze_post(text: str) -> tuple[dict, dict | None]:
|
| 26 |
+
"""Return (classification, analysis-or-None) for one post text."""
|
| 27 |
+
from analyzer import analyze, verify
|
| 28 |
+
from classifier import get_classifier
|
| 29 |
+
from manip_classifier import get_manip_classifier
|
| 30 |
+
|
| 31 |
+
with _clf_lock:
|
| 32 |
+
hate = get_classifier().classify(text)
|
| 33 |
+
manip = get_manip_classifier().classify(text)
|
| 34 |
+
|
| 35 |
+
hate_conf = float(hate.get("confidence", 0.0))
|
| 36 |
+
manip_conf = float(manip.get("confidence", 0.0))
|
| 37 |
+
hate_flag = bool(hate.get("flagged"))
|
| 38 |
+
manip_flag = bool(manip.get("flagged"))
|
| 39 |
+
flagged = hate_flag or manip_flag
|
| 40 |
+
|
| 41 |
+
analysis = None
|
| 42 |
+
verification = None
|
| 43 |
+
kind = None
|
| 44 |
+
if flagged:
|
| 45 |
+
kind = "hate" if hate_flag else "manipulation"
|
| 46 |
+
analysis = analyze(text, kind)
|
| 47 |
+
verification = verify(text, analysis, kind)
|
| 48 |
+
# Overturn (false positive) if EITHER the verifier judged the analysis
|
| 49 |
+
# irrelevant, OR the rights-grounded analysis found no concrete technique/
|
| 50 |
+
# highlight (deterministic, speech-protective guard for both kinds).
|
| 51 |
+
empty = not analysis.get("techniques") and not analysis.get("highlights")
|
| 52 |
+
if not verification.get("relevant", True) or empty:
|
| 53 |
+
flagged = False
|
| 54 |
+
analysis = None
|
| 55 |
+
kind = None
|
| 56 |
+
|
| 57 |
+
trigger_conf = hate_conf if (kind == "hate") else manip_conf if (kind == "manipulation") else max(hate_conf, manip_conf)
|
| 58 |
+
classification = {
|
| 59 |
+
"label": "FLAGGED" if flagged else "CLEAR",
|
| 60 |
+
"flagged": flagged,
|
| 61 |
+
"kind": kind if flagged else None,
|
| 62 |
+
"confidence": round(trigger_conf, 4),
|
| 63 |
+
"hate_confidence": round(hate_conf, 4),
|
| 64 |
+
"manipulation_confidence": round(manip_conf, 4),
|
| 65 |
+
"high_confidence": flagged and trigger_conf >= HIGH_CONFIDENCE,
|
| 66 |
+
"truncated": bool(hate.get("truncated")),
|
| 67 |
+
"raw_scores": hate.get("raw_scores", {}),
|
| 68 |
+
"model": hate.get("model"),
|
| 69 |
+
"verification": verification,
|
| 70 |
+
}
|
| 71 |
+
return classification, analysis
|
backend/rebuild.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared cache-rebuild routine used by both build_cache.py and POST /rebuild-cache.
|
| 2 |
+
|
| 3 |
+
Serving is real-time now (pipeline.py), so this is only for building an offline
|
| 4 |
+
snapshot. It runs the same two-gate pipeline (local hate model + LLM manipulation
|
| 5 |
+
detector/explainer) over every post and writes data/cache.json.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from typing import Callable, Optional
|
| 12 |
+
|
| 13 |
+
import config
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger("infoshield.rebuild")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def rebuild_cache(cache, posts, on_post: Optional[Callable[[str, dict], None]] = None) -> dict:
|
| 19 |
+
"""Reclassify + re-analyze all posts into `cache` (in-memory + on disk).
|
| 20 |
+
|
| 21 |
+
`on_post(post_id, classification)` is an optional progress callback.
|
| 22 |
+
"""
|
| 23 |
+
from classifier import get_classifier
|
| 24 |
+
from llm import available as llm_available
|
| 25 |
+
from pipeline import analyze_post
|
| 26 |
+
|
| 27 |
+
clf = get_classifier()
|
| 28 |
+
clf.load() # device select + warmup + label sanity check
|
| 29 |
+
|
| 30 |
+
flagged = 0
|
| 31 |
+
for post in posts:
|
| 32 |
+
classification, analysis = analyze_post(post["text"])
|
| 33 |
+
if classification["flagged"]:
|
| 34 |
+
flagged += 1
|
| 35 |
+
cache.set(post["id"], classification, analysis)
|
| 36 |
+
if on_post:
|
| 37 |
+
on_post(post["id"], classification)
|
| 38 |
+
|
| 39 |
+
cache.set_meta(
|
| 40 |
+
model=clf.model_name,
|
| 41 |
+
device=clf.device,
|
| 42 |
+
warmup_ms=clf.warmup_ms,
|
| 43 |
+
llm_provider=config.LLM_PROVIDER,
|
| 44 |
+
llm=llm_available(),
|
| 45 |
+
built_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 46 |
+
)
|
| 47 |
+
cache.save()
|
| 48 |
+
|
| 49 |
+
summary = {
|
| 50 |
+
"posts": len(posts),
|
| 51 |
+
"flagged": flagged,
|
| 52 |
+
"model": clf.model_name,
|
| 53 |
+
"device": clf.device,
|
| 54 |
+
"llm_provider": config.LLM_PROVIDER,
|
| 55 |
+
"llm": llm_available(),
|
| 56 |
+
}
|
| 57 |
+
log.info("Rebuild complete: %s", summary)
|
| 58 |
+
return summary
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core (required to serve the cache-first offline demo)
|
| 2 |
+
flask>=3.0
|
| 3 |
+
flask-cors>=4.0
|
| 4 |
+
python-dotenv>=1.0
|
| 5 |
+
|
| 6 |
+
# Local classifier (required to rebuild the cache / run /classify live)
|
| 7 |
+
torch>=2.2
|
| 8 |
+
transformers>=4.40
|
| 9 |
+
# transformers needs one of safetensors/sentencepiece for these checkpoints
|
| 10 |
+
safetensors>=0.4
|
| 11 |
+
sentencepiece>=0.2
|
| 12 |
+
|
| 13 |
+
# LLM manipulation detection + analysis. Groq is the default provider; Gemini
|
| 14 |
+
# is the swap-in (set INFOSHIELD_LLM_PROVIDER=gemini).
|
| 15 |
+
groq>=0.11
|
| 16 |
+
google-genai>=0.3
|
| 17 |
+
|
| 18 |
+
# Helpers
|
| 19 |
+
huggingface_hub>=0.23
|
| 20 |
+
emoji>=2.0 # BERTweet tokenizer (English fallback manipulation model) needs this
|
backend/scripts/build_cache.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the demo cache with the REAL pipeline: local classifier + Gemini.
|
| 2 |
+
|
| 3 |
+
For every seed post: classify locally (content-only, author-agnostic), and if
|
| 4 |
+
flagged, generate an educational analysis via Gemini (text only). Persists to
|
| 5 |
+
data/cache.json. Same routine the backend exposes at POST /rebuild-cache.
|
| 6 |
+
|
| 7 |
+
Requires torch + transformers installed; Gemini is optional (falls back to a
|
| 8 |
+
local heuristic analysis if GEMINI_API_KEY is unset).
|
| 9 |
+
|
| 10 |
+
Run: python backend/scripts/build_cache.py
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 18 |
+
sys.path.insert(0, str(ROOT))
|
| 19 |
+
|
| 20 |
+
from analyzer import available as gemini_available # noqa: E402
|
| 21 |
+
from cache import get_cache, load_posts # noqa: E402
|
| 22 |
+
from rebuild import rebuild_cache # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> None:
|
| 26 |
+
posts = load_posts()
|
| 27 |
+
cache = get_cache()
|
| 28 |
+
print(f"Gemini available: {gemini_available()}")
|
| 29 |
+
print(f"Building cache for {len(posts)} posts ...\n")
|
| 30 |
+
|
| 31 |
+
def progress(pid: str, classification: dict) -> None:
|
| 32 |
+
tag = "FLAG" if classification["flagged"] else "ok "
|
| 33 |
+
print(f" [{tag}] {pid:>3} conf={classification['confidence']:.2f}")
|
| 34 |
+
|
| 35 |
+
summary = rebuild_cache(cache, posts, on_post=progress)
|
| 36 |
+
print(f"\nDone. {summary['flagged']} flagged / {summary['posts']} posts "
|
| 37 |
+
f"on {summary['device']}. Cache written.")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
main()
|
backend/scripts/download_models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-time, run-while-online model download.
|
| 2 |
+
|
| 3 |
+
Pulls the primary (and fallback) hate-speech checkpoints into the local
|
| 4 |
+
Hugging Face cache so the demo can later run with Wi-Fi off.
|
| 5 |
+
|
| 6 |
+
Run: python backend/scripts/download_models.py
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
sys.path.insert(0, str(ROOT))
|
| 15 |
+
from config import FALLBACK_MODEL, PRIMARY_MODEL # noqa: E402
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def fetch(name: str) -> bool:
|
| 19 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 20 |
+
|
| 21 |
+
print(f"\n=== Downloading {name} ===")
|
| 22 |
+
try:
|
| 23 |
+
AutoTokenizer.from_pretrained(name)
|
| 24 |
+
AutoModelForSequenceClassification.from_pretrained(name)
|
| 25 |
+
print(f" OK: {name} cached.")
|
| 26 |
+
return True
|
| 27 |
+
except Exception as exc: # noqa: BLE001
|
| 28 |
+
print(f" FAILED: {name} -> {exc}")
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
ok_primary = fetch(PRIMARY_MODEL)
|
| 34 |
+
ok_fallback = fetch(FALLBACK_MODEL)
|
| 35 |
+
if not ok_primary and not ok_fallback:
|
| 36 |
+
raise SystemExit("Could not download any classifier model. Check your connection.")
|
| 37 |
+
print("\nDone. You can now build the cache (build_cache.py) and run offline.")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
main()
|
backend/scripts/evaluate.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate a gate and find its optimal threshold on a labeled CSV.
|
| 2 |
+
|
| 3 |
+
Scores every row with one gate, then reports precision / recall / F1, ROC-AUC,
|
| 4 |
+
PR-AUC, a threshold sweep, and three recommended operating points
|
| 5 |
+
(max-F1, Youden's J, target-precision). See docs/EVALUATION.md for the method.
|
| 6 |
+
|
| 7 |
+
CSV format: columns `text,label` (label: 1 = hate/manipulation, 0 = not).
|
| 8 |
+
|
| 9 |
+
Examples:
|
| 10 |
+
python backend/scripts/evaluate.py --task hate --data hate.csv --subset 0.1
|
| 11 |
+
python backend/scripts/evaluate.py --task manipulation --data persuasion.csv --subset 0.2
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import csv
|
| 17 |
+
import random
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 24 |
+
sys.path.insert(0, str(ROOT))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def load_rows(path: str) -> list[tuple[str, int]]:
|
| 28 |
+
rows: list[tuple[str, int]] = []
|
| 29 |
+
with open(path, "r", encoding="utf-8", newline="") as f:
|
| 30 |
+
reader = csv.DictReader(f)
|
| 31 |
+
for r in reader:
|
| 32 |
+
text = (r.get("text") or "").strip()
|
| 33 |
+
raw = str(r.get("label") or "").strip()
|
| 34 |
+
if not text or raw == "":
|
| 35 |
+
continue
|
| 36 |
+
try:
|
| 37 |
+
label = int(float(raw))
|
| 38 |
+
except ValueError:
|
| 39 |
+
continue
|
| 40 |
+
rows.append((text, 1 if label >= 1 else 0))
|
| 41 |
+
return rows
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def stratified_subset(rows, fraction, seed):
|
| 45 |
+
if fraction >= 1.0:
|
| 46 |
+
return rows
|
| 47 |
+
rng = random.Random(seed)
|
| 48 |
+
pos = [r for r in rows if r[1] == 1]
|
| 49 |
+
neg = [r for r in rows if r[1] == 0]
|
| 50 |
+
rng.shuffle(pos)
|
| 51 |
+
rng.shuffle(neg)
|
| 52 |
+
keep = pos[: max(1, int(len(pos) * fraction))] + neg[: max(1, int(len(neg) * fraction))]
|
| 53 |
+
rng.shuffle(keep)
|
| 54 |
+
return keep
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def score_rows(rows, task):
|
| 58 |
+
scores = []
|
| 59 |
+
if task == "hate":
|
| 60 |
+
from classifier import get_classifier
|
| 61 |
+
|
| 62 |
+
clf = get_classifier()
|
| 63 |
+
clf.load()
|
| 64 |
+
for i, (text, _) in enumerate(rows, 1):
|
| 65 |
+
scores.append(float(clf.classify(text)["confidence"]))
|
| 66 |
+
if i % 25 == 0:
|
| 67 |
+
print(f" scored {i}/{len(rows)}", file=sys.stderr)
|
| 68 |
+
else: # manipulation (local propaganda model -- the gate score)
|
| 69 |
+
from manip_classifier import get_manip_classifier
|
| 70 |
+
|
| 71 |
+
clf = get_manip_classifier()
|
| 72 |
+
clf.load()
|
| 73 |
+
for i, (text, _) in enumerate(rows, 1):
|
| 74 |
+
scores.append(float(clf.classify(text)["confidence"]))
|
| 75 |
+
if i % 25 == 0:
|
| 76 |
+
print(f" scored {i}/{len(rows)}", file=sys.stderr)
|
| 77 |
+
return np.array(scores), np.array([lbl for _, lbl in rows])
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def metrics_at(scores, labels, t):
|
| 81 |
+
pred = scores >= t
|
| 82 |
+
tp = int(np.sum(pred & (labels == 1)))
|
| 83 |
+
fp = int(np.sum(pred & (labels == 0)))
|
| 84 |
+
fn = int(np.sum(~pred & (labels == 1)))
|
| 85 |
+
tn = int(np.sum(~pred & (labels == 0)))
|
| 86 |
+
prec = tp / (tp + fp) if tp + fp else 0.0
|
| 87 |
+
rec = tp / (tp + fn) if tp + fn else 0.0
|
| 88 |
+
f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
|
| 89 |
+
tpr = rec
|
| 90 |
+
fpr = fp / (fp + tn) if fp + tn else 0.0
|
| 91 |
+
return {"t": t, "tp": tp, "fp": fp, "fn": fn, "tn": tn,
|
| 92 |
+
"precision": prec, "recall": rec, "f1": f1, "tpr": tpr, "fpr": fpr}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def roc_auc(scores, labels):
|
| 96 |
+
pos = scores[labels == 1]
|
| 97 |
+
neg = scores[labels == 0]
|
| 98 |
+
if len(pos) == 0 or len(neg) == 0:
|
| 99 |
+
return float("nan")
|
| 100 |
+
# Mann-Whitney U (counts ties as 0.5).
|
| 101 |
+
gt = np.sum(pos[:, None] > neg[None, :])
|
| 102 |
+
eq = np.sum(pos[:, None] == neg[None, :])
|
| 103 |
+
return float((gt + 0.5 * eq) / (len(pos) * len(neg)))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def pr_auc(scores, labels):
|
| 107 |
+
order = np.argsort(-scores)
|
| 108 |
+
y = labels[order]
|
| 109 |
+
tp = np.cumsum(y == 1)
|
| 110 |
+
fp = np.cumsum(y == 0)
|
| 111 |
+
npos = int(np.sum(labels == 1))
|
| 112 |
+
if npos == 0:
|
| 113 |
+
return float("nan")
|
| 114 |
+
precision = tp / np.maximum(tp + fp, 1)
|
| 115 |
+
recall = tp / npos
|
| 116 |
+
recall = np.concatenate([[0.0], recall])
|
| 117 |
+
precision = np.concatenate([[1.0], precision])
|
| 118 |
+
return float(np.sum((recall[1:] - recall[:-1]) * precision[1:])) # average precision
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main() -> None:
|
| 122 |
+
ap = argparse.ArgumentParser()
|
| 123 |
+
ap.add_argument("--task", choices=["hate", "manipulation"], required=True)
|
| 124 |
+
ap.add_argument("--data", required=True, help="CSV with columns: text,label")
|
| 125 |
+
ap.add_argument("--subset", type=float, default=1.0, help="stratified fraction, e.g. 0.1")
|
| 126 |
+
ap.add_argument("--seed", type=int, default=13)
|
| 127 |
+
ap.add_argument("--target-precision", type=float, default=0.85)
|
| 128 |
+
ap.add_argument("--steps", type=int, default=101)
|
| 129 |
+
args = ap.parse_args()
|
| 130 |
+
|
| 131 |
+
rows = stratified_subset(load_rows(args.data), args.subset, args.seed)
|
| 132 |
+
if not rows:
|
| 133 |
+
raise SystemExit("No usable rows (need columns text,label).")
|
| 134 |
+
print(f"\nTask: {args.task} | rows: {len(rows)} "
|
| 135 |
+
f"(pos={sum(l for _, l in rows)}, neg={sum(1 - l for _, l in rows)})")
|
| 136 |
+
|
| 137 |
+
scores, labels = score_rows(rows, args.task)
|
| 138 |
+
sweep = [metrics_at(scores, labels, t) for t in np.linspace(0, 1, args.steps)]
|
| 139 |
+
|
| 140 |
+
best_f1 = max(sweep, key=lambda m: m["f1"])
|
| 141 |
+
youden = max(sweep, key=lambda m: m["tpr"] - m["fpr"])
|
| 142 |
+
prec_ok = [m for m in sweep if m["precision"] >= args.target_precision and m["recall"] > 0]
|
| 143 |
+
target = min(prec_ok, key=lambda m: m["t"]) if prec_ok else best_f1
|
| 144 |
+
|
| 145 |
+
print(f"\nROC-AUC: {roc_auc(scores, labels):.3f} PR-AUC (AP): {pr_auc(scores, labels):.3f}")
|
| 146 |
+
print("\nOperating points:")
|
| 147 |
+
print(f" {'criterion':<22}{'thr':>6}{'P':>8}{'R':>8}{'F1':>8}")
|
| 148 |
+
for name, m in [("max-F1", best_f1), ("Youden's J", youden),
|
| 149 |
+
(f"precision>={args.target_precision:.2f}", target)]:
|
| 150 |
+
print(f" {name:<22}{m['t']:>6.2f}{m['precision']:>8.3f}{m['recall']:>8.3f}{m['f1']:>8.3f}")
|
| 151 |
+
|
| 152 |
+
r = target
|
| 153 |
+
print(f"\nRecommended threshold = {r['t']:.2f} (precision-first). Confusion @ thr:")
|
| 154 |
+
print(f" TP={r['tp']} FP={r['fp']} FN={r['fn']} TN={r['tn']}")
|
| 155 |
+
print("\nPaste the operating-points table into docs/EVALUATION.md > Key results.\n")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
main()
|
backend/scripts/finetune.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OPTIONAL fine-tuning scaffold. The demo does NOT depend on this.
|
| 2 |
+
|
| 3 |
+
Fine-tunes the primary checkpoint on a small local CSV (columns: text,label
|
| 4 |
+
where label is 0=not-hate, 1=hate). Useful if you want to adapt the classifier
|
| 5 |
+
to your own examples. Produces a model directory you could point PRIMARY_MODEL at.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python backend/scripts/finetune.py --data my_data.csv --out backend/models/finetuned --epochs 2
|
| 9 |
+
|
| 10 |
+
Requires: transformers[torch], datasets, accelerate.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 19 |
+
sys.path.insert(0, str(ROOT))
|
| 20 |
+
from config import PRIMARY_MODEL # noqa: E402
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
ap = argparse.ArgumentParser()
|
| 25 |
+
ap.add_argument("--data", required=True, help="CSV with columns: text,label")
|
| 26 |
+
ap.add_argument("--out", default=str(ROOT / "models" / "finetuned"))
|
| 27 |
+
ap.add_argument("--epochs", type=float, default=2.0)
|
| 28 |
+
ap.add_argument("--lr", type=float, default=2e-5)
|
| 29 |
+
ap.add_argument("--batch", type=int, default=8)
|
| 30 |
+
args = ap.parse_args()
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
import numpy as np
|
| 34 |
+
from datasets import load_dataset
|
| 35 |
+
from transformers import (
|
| 36 |
+
AutoModelForSequenceClassification,
|
| 37 |
+
AutoTokenizer,
|
| 38 |
+
DataCollatorWithPadding,
|
| 39 |
+
Trainer,
|
| 40 |
+
TrainingArguments,
|
| 41 |
+
)
|
| 42 |
+
except ImportError as exc: # noqa: BLE001
|
| 43 |
+
raise SystemExit(
|
| 44 |
+
f"Missing dependency: {exc}. Install with: pip install datasets accelerate"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
tok = AutoTokenizer.from_pretrained(PRIMARY_MODEL)
|
| 48 |
+
model = AutoModelForSequenceClassification.from_pretrained(PRIMARY_MODEL, num_labels=2)
|
| 49 |
+
|
| 50 |
+
ds = load_dataset("csv", data_files=args.data)["train"].train_test_split(test_size=0.2)
|
| 51 |
+
ds = ds.map(lambda b: tok(b["text"], truncation=True, max_length=256), batched=True)
|
| 52 |
+
|
| 53 |
+
def metrics(p):
|
| 54 |
+
preds = np.argmax(p.predictions, axis=-1)
|
| 55 |
+
return {"accuracy": float((preds == p.label_ids).mean())}
|
| 56 |
+
|
| 57 |
+
targs = TrainingArguments(
|
| 58 |
+
output_dir=args.out,
|
| 59 |
+
num_train_epochs=args.epochs,
|
| 60 |
+
learning_rate=args.lr,
|
| 61 |
+
per_device_train_batch_size=args.batch,
|
| 62 |
+
per_device_eval_batch_size=args.batch,
|
| 63 |
+
eval_strategy="epoch",
|
| 64 |
+
save_strategy="epoch",
|
| 65 |
+
logging_steps=10,
|
| 66 |
+
)
|
| 67 |
+
trainer = Trainer(
|
| 68 |
+
model=model,
|
| 69 |
+
args=targs,
|
| 70 |
+
train_dataset=ds["train"],
|
| 71 |
+
eval_dataset=ds["test"],
|
| 72 |
+
tokenizer=tok,
|
| 73 |
+
data_collator=DataCollatorWithPadding(tok),
|
| 74 |
+
compute_metrics=metrics,
|
| 75 |
+
)
|
| 76 |
+
trainer.train()
|
| 77 |
+
trainer.save_model(args.out)
|
| 78 |
+
tok.save_pretrained(args.out)
|
| 79 |
+
print(f"\nSaved fine-tuned model to {args.out}")
|
| 80 |
+
print("To use it, set PRIMARY_MODEL in backend/config.py to that path.")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
main()
|
backend/scripts/seed_demo.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author the demo feed (posts.json) plus a neutral starter cache (cache.json).
|
| 2 |
+
|
| 3 |
+
This writes ONLY post content/metadata. Classification + analysis come from the
|
| 4 |
+
real pipeline via `build_cache.py` (or POST /rebuild-cache). The starter cache
|
| 5 |
+
marks every post unflagged so the app serves a valid feed before the first
|
| 6 |
+
rebuild; running the real pipeline then fills in flags + analyses.
|
| 7 |
+
|
| 8 |
+
All harmful examples are synthetic, mild-to-moderate, and reference only
|
| 9 |
+
unnamed/fictional groups (never real ethnic, religious, or national groups).
|
| 10 |
+
|
| 11 |
+
Run: python backend/scripts/seed_demo.py
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 19 |
+
DATA = ROOT / "data"
|
| 20 |
+
|
| 21 |
+
USERS = {
|
| 22 |
+
"maya": {"name": "Maya Chen", "handle": "maya_codes", "initial": "M", "color": "#2563EB", "verified": False},
|
| 23 |
+
"dave": {"name": "Dave R.", "handle": "grillmaster_dave","initial": "D", "color": "#D97706", "verified": False},
|
| 24 |
+
"priya": {"name": "Priya K.", "handle": "aerialnomad", "initial": "P", "color": "#0D9488", "verified": True},
|
| 25 |
+
"lena": {"name": "Lena Ortiz", "handle": "lena_writes", "initial": "L", "color": "#DB2777", "verified": False},
|
| 26 |
+
"sam": {"name": "Sam · RN", "handle": "nightshift_sam", "initial": "S", "color": "#7C3AED", "verified": False},
|
| 27 |
+
"tom": {"name": "Tom B.", "handle": "tom_b", "initial": "T", "color": "#0F172A", "verified": False},
|
| 28 |
+
"anna": {"name": "Anna Whitfield", "handle": "historybuff_anna","initial": "A", "color": "#9333EA", "verified": True},
|
| 29 |
+
"truth": {"name": "Truth Hour", "handle": "TheRealTruthHour","initial": "T", "color": "#DC2626", "verified": False},
|
| 30 |
+
"wake": {"name": "Open Your Eyes", "handle": "wakeup_sheeple", "initial": "O", "color": "#7C2D12", "verified": False},
|
| 31 |
+
"brad": {"name": "Brad // crypto", "handle": "cryptobrad", "initial": "B", "color": "#F59E0B", "verified": False},
|
| 32 |
+
"radio": {"name": "Real Talk Radio", "handle": "realtalkradio", "initial": "R", "color": "#B91C1C", "verified": False},
|
| 33 |
+
"parent": {"name": "Concerned Parent", "handle": "concerned_parent_99","initial": "C","color": "#92400E","verified": False},
|
| 34 |
+
"doom": {"name": "doomscroller", "handle": "doomscroller", "initial": "d", "color": "#475569", "verified": False},
|
| 35 |
+
"quiet": {"name": "just asking", "handle": "quiet_observer", "initial": "j", "color": "#64748B", "verified": False},
|
| 36 |
+
"mike": {"name": "Mike", "handle": "patriot_mike49", "initial": "M", "color": "#991B1B", "verified": False},
|
| 37 |
+
"zephyr": {"name": "ZephyrX", "handle": "ZephyrX_", "initial": "Z", "color": "#1F2937", "verified": False},
|
| 38 |
+
"border": {"name": "Borderwatch", "handle": "borderwatch_now", "initial": "B", "color": "#7F1D1D", "verified": False},
|
| 39 |
+
"oldtown": {"name": "Old Town Voice", "handle": "oldtownvoice", "initial": "O", "color": "#78350F", "verified": False},
|
| 40 |
+
"jane": {"name": "Jane", "handle": "realtalk_jane", "initial": "J", "color": "#a16207", "verified": False},
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
POSTS = []
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def add(pid, user, time, text, *, media=None, replies=0, reposts=0, likes=0):
|
| 47 |
+
POSTS.append({
|
| 48 |
+
"id": pid,
|
| 49 |
+
"user": USERS[user],
|
| 50 |
+
"time": time,
|
| 51 |
+
"text": text,
|
| 52 |
+
"media": media,
|
| 53 |
+
"engagement": {"replies": replies, "reposts": reposts, "likes": likes},
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---- Normal posts --------------------------------------------------------
|
| 58 |
+
add("p1", "maya", "12m",
|
| 59 |
+
"spent three hours debugging today only to realize i'd been editing the wrong file the entire time. "
|
| 60 |
+
"anyway. how is everyone's monday going",
|
| 61 |
+
replies=156, reposts=89, likes=1243)
|
| 62 |
+
|
| 63 |
+
add("p2", "dave", "34m",
|
| 64 |
+
"Unpopular opinion: pineapple absolutely belongs on pizza and I'm tired of pretending it doesn't \U0001F34D",
|
| 65 |
+
replies=890, reposts=320, likes=4500)
|
| 66 |
+
|
| 67 |
+
add("p3", "priya", "1h",
|
| 68 |
+
"Sunrise over the valley in Patagonia right now. Some places just don't feel real until you're standing in them. \U0001F3D4️",
|
| 69 |
+
media={"type": "gradient"}, replies=230, reposts=1200, likes=8900)
|
| 70 |
+
|
| 71 |
+
add("p4", "lena", "2h",
|
| 72 |
+
"finished the first draft of my novel at 2am and immediately started doubting every single word of it. "
|
| 73 |
+
"writing is just professionally arguing with yourself.",
|
| 74 |
+
replies=98, reposts=340, likes=2100)
|
| 75 |
+
|
| 76 |
+
add("p5", "sam", "3h",
|
| 77 |
+
"12 hour shift done. feet wrecked, heart full. a patient's family brought the whole unit donuts today "
|
| 78 |
+
"and i nearly cried in the break room. it's the small stuff.",
|
| 79 |
+
replies=145, reposts=210, likes=3400)
|
| 80 |
+
|
| 81 |
+
add("p6", "tom", "4h", "is it coffee o'clock yet", replies=34, reposts=12, likes=540)
|
| 82 |
+
|
| 83 |
+
add("p7", "anna", "5h",
|
| 84 |
+
"people ask why i love history and honestly it's because nothing is ever as simple as the textbook makes it "
|
| 85 |
+
"sound. every 'obvious' decision was made by a real person who couldn't see the ending, who was scared and "
|
| 86 |
+
"tired and working with bad information. we read the past like the outcome was inevitable, but the people "
|
| 87 |
+
"living it had no idea which way it would go. that's the part that stays with me, not the dates or the "
|
| 88 |
+
"battles but the uncertainty. it makes me a lot gentler about the present, knowing future generations will "
|
| 89 |
+
"flatten us into a tidy paragraph too.",
|
| 90 |
+
replies=210, reposts=980, likes=5600)
|
| 91 |
+
|
| 92 |
+
# ---- Manipulation posts --------------------------------------------------
|
| 93 |
+
add("p8", "truth", "26m",
|
| 94 |
+
"They don't want you to know this, but the new digital ID rolling out next month means they can switch off "
|
| 95 |
+
"your bank account with a single click. This is NOT a conspiracy. Screenshot this before it gets taken down. ⏰",
|
| 96 |
+
replies=3400, reposts=8900, likes=12000)
|
| 97 |
+
|
| 98 |
+
add("p9", "wake", "1h",
|
| 99 |
+
"Wake up. Everything you were taught was designed to keep you obedient and quiet. The people at the top are "
|
| 100 |
+
"laughing at how easy it was. Stop trusting them and start trusting your gut.",
|
| 101 |
+
replies=890, reposts=4200, likes=6700)
|
| 102 |
+
|
| 103 |
+
add("p10", "brad", "1h",
|
| 104 |
+
"\U0001F6A8 ONLY 100 spots left. This is your LAST chance to get in before the price 10x's. The people who "
|
| 105 |
+
"hesitate will be kicking themselves in 6 months. DM me NOW or stay broke. \U0001F449 https://t.co/x9f2aQ",
|
| 106 |
+
replies=1200, reposts=450, likes=890)
|
| 107 |
+
|
| 108 |
+
add("p11", "radio", "2h",
|
| 109 |
+
"Notice how every time regular people start asking real questions, there's suddenly a brand new crisis to "
|
| 110 |
+
"distract everyone? That's not a coincidence. They've been running this exact playbook for decades.",
|
| 111 |
+
replies=780, reposts=5600, likes=9800)
|
| 112 |
+
|
| 113 |
+
add("p12", "parent", "3h",
|
| 114 |
+
"If you don't share this, don't call yourself a real parent. They are putting things into the curriculum "
|
| 115 |
+
"that will destroy your child's mind by next year. Act before it's too late.",
|
| 116 |
+
replies=2300, reposts=6700, likes=4500)
|
| 117 |
+
|
| 118 |
+
add("p13", "doom", "3h",
|
| 119 |
+
"oh good, another 'once in a lifetime' crisis this week. truly we are so blessed to live through the single "
|
| 120 |
+
"most important moment in human history every monday \U0001F644",
|
| 121 |
+
replies=120, reposts=670, likes=3200)
|
| 122 |
+
|
| 123 |
+
# ---- Borderline ----------------------------------------------------------
|
| 124 |
+
add("p14", "quiet", "4h",
|
| 125 |
+
"i'm not saying anything is definitely wrong with the new policy... but doesn't it feel a little convenient? "
|
| 126 |
+
"a lot of people are quietly starting to wonder, and maybe that's worth paying attention to.",
|
| 127 |
+
replies=200, reposts=340, likes=1200)
|
| 128 |
+
|
| 129 |
+
# ---- Hate speech (synthetic, unnamed groups) -----------------------------
|
| 130 |
+
add("p15", "mike", "5h",
|
| 131 |
+
"These people pour into our town, don't bother learning how we live, and expect us to bend over backwards "
|
| 132 |
+
"for them. They breed faster than we can keep up and soon there'll be nothing left of who we are. Enough.",
|
| 133 |
+
replies=1200, reposts=3400, likes=5600)
|
| 134 |
+
|
| 135 |
+
add("p16", "zephyr", "6h",
|
| 136 |
+
"Every one of them is the same — vermin who swarm in and bleed this place dry. They don't belong here and "
|
| 137 |
+
"they should be driven out before there's nothing left.",
|
| 138 |
+
replies=890, reposts=1100, likes=2300)
|
| 139 |
+
|
| 140 |
+
add("p17", "border", "7h",
|
| 141 |
+
"They're animals, plain and simple, and you can't civilize an animal. People like that should be removed "
|
| 142 |
+
"from our streets before they ruin everything.",
|
| 143 |
+
replies=1500, reposts=2100, likes=3400)
|
| 144 |
+
|
| 145 |
+
add("p18", "oldtown", "9h",
|
| 146 |
+
"They've infested every neighborhood they've touched and turned it into a dump. Pack them up and send them "
|
| 147 |
+
"back where they came from before there's nothing left of this place.",
|
| 148 |
+
replies=1900, reposts=2800, likes=4500)
|
| 149 |
+
|
| 150 |
+
add("p19", "jane", "11h",
|
| 151 |
+
"funny how a certain kind of people always seem to end up at the center of every single problem in this city. "
|
| 152 |
+
"at some point you stop calling it a coincidence.",
|
| 153 |
+
replies=600, reposts=900, likes=1800)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def write_starter_cache() -> None:
|
| 157 |
+
"""Neutral cache so the app serves a valid feed before the first rebuild."""
|
| 158 |
+
posts = {
|
| 159 |
+
p["id"]: {
|
| 160 |
+
"classification": {
|
| 161 |
+
"label": "NOT_HATE", "flagged": False, "confidence": 0.0,
|
| 162 |
+
"high_confidence": False, "truncated": False, "raw_scores": {},
|
| 163 |
+
"model": "starter (unanalyzed)",
|
| 164 |
+
},
|
| 165 |
+
"analysis": None,
|
| 166 |
+
}
|
| 167 |
+
for p in POSTS
|
| 168 |
+
}
|
| 169 |
+
payload = {
|
| 170 |
+
"meta": {
|
| 171 |
+
"built_at": None,
|
| 172 |
+
"model": "starter (unanalyzed)",
|
| 173 |
+
"device": "n/a",
|
| 174 |
+
"note": "Neutral starter cache. Run build_cache.py or POST /rebuild-cache for real analysis.",
|
| 175 |
+
},
|
| 176 |
+
"posts": posts,
|
| 177 |
+
}
|
| 178 |
+
with open(DATA / "cache.json", "w", encoding="utf-8") as f:
|
| 179 |
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def main() -> None:
|
| 183 |
+
DATA.mkdir(parents=True, exist_ok=True)
|
| 184 |
+
with open(DATA / "posts.json", "w", encoding="utf-8") as f:
|
| 185 |
+
json.dump(POSTS, f, ensure_ascii=False, indent=2)
|
| 186 |
+
if not (DATA / "cache.json").exists():
|
| 187 |
+
write_starter_cache()
|
| 188 |
+
print(f"Wrote {len(POSTS)} posts -> posts.json")
|
| 189 |
+
print("Run build_cache.py (or POST /rebuild-cache) to generate real classification + analysis.")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
main()
|