Pavle-17 Claude Opus 4.8 commited on
Commit
1cf82f0
·
1 Parent(s): 5e1ab43

InfoShield backend (Docker)

Browse files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.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: red
5
- colorTo: pink
6
  sdk: docker
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
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()