fborj commited on
Commit
047e937
Β·
1 Parent(s): be0a973

Integrated pattern deviation, fixed Cebuano modeling, and added ISO Survey

Browse files
backend/train.py CHANGED
@@ -1000,24 +1000,31 @@ def load_fake_news_dataset(
1000
 
1001
  if cebuano_only:
1002
  # ── Cebuano-only mode ──────────────────────────────────────────────
1003
- # Real: CebuaNER | Fake: MT-translated from jcblaise Tagalog fakes
1004
  print(" Mode: CEBUANO-ONLY")
1005
 
 
 
 
1006
  df3 = _load_cebuaner_as_dataframe()
1007
  if df3 is not None:
 
 
 
 
 
 
1008
  frames.append(df3)
1009
 
1010
- df_ceb_fake = _augment_fake_news_with_translation(target_lang="ceb")
1011
  if df_ceb_fake is not None:
1012
  frames.append(df_ceb_fake)
1013
 
1014
  elif tagalog_only:
1015
  # ── Tagalog-only mode ──────────────────────────────────────────────
1016
- # Strategy: trust known Filipino datasets as-is; only run langdetect
1017
- # on the Philippine Corpus (which is 99.6% English) to extract its
1018
- # small Tagalog-credible subset. This preserves all 1,603 jcblaise
1019
- # fake articles instead of the ~71 that survived the old global filter.
1020
- print(" Mode: TAGALOG-ONLY (per-dataset filtering)")
1021
 
1022
  # [1] jcblaise β€” labeled Filipino fake-news corpus, load ALL rows
1023
  csv1 = os.path.join(
@@ -1077,15 +1084,43 @@ def load_fake_news_dataset(
1077
  # [3] CebuaNER β€” skip (Cebuano, not Tagalog)
1078
  print(" [3] josephimperial/CebuaNER: skipped (Tagalog-only mode)")
1079
 
1080
- # [4] BalitaNLP β€” trusted Filipino dataset, load all (no langdetect needed)
1081
- df4 = _load_balitanlp_as_dataframe()
1082
- if df4 is not None:
1083
- frames.append(df4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1084
 
1085
- # [aug] MT-translate English "Not Credible" β†’ Tagalog fakes (~500 articles)
1086
  df_tl_fake = _augment_fake_news_with_translation(target_lang="tl")
1087
  if df_tl_fake is not None:
1088
  frames.append(df_tl_fake)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1089
 
1090
  else:
1091
  # ── Mixed mode: all datasets, no language filter ──
@@ -1140,12 +1175,32 @@ def load_fake_news_dataset(
1140
  # [4] BalitaNLP β€” skipped in mixed mode (use --tagalog-only to include)
1141
  print(" [4] LanceBunag/BalitaNLP: skipped (use --tagalog-only to include)")
1142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1143
  if not frames:
1144
  raise FileNotFoundError(
1145
  "No datasets found! Place at least one dataset in data/raw/."
1146
  )
1147
 
1148
  # ── Merge and deduplicate ──
 
1149
  df = pd.concat(frames, ignore_index=True)
1150
  df = df.dropna(subset=["article"]).copy()
1151
  df = df[df["article"].str.len() > 0].copy()
@@ -1180,11 +1235,12 @@ def preprocess(df, undersample=False, oversample=True):
1180
 
1181
  Balancing strategy (applied in order of preference):
1182
  1. oversample=True β€” RandomOverSampler duplicates minority-class (Fake) rows
1183
- until classes are equal. Preferred for small Tagalog/Cebuano
1184
- datasets where downsampling would waste Real data.
1185
  2. undersample=True β€” downsample the majority class (legacy fallback).
1186
  Both: oversample is applied first, then undersample (rarely needed).
1187
- Neither: return data as-is (class_weight='balanced' in RF handles the rest).
 
1188
 
1189
  class_weight='balanced' is ALSO set on the RandomForest, so even without
1190
  resampling the model still penalises fake-news misclassification more.
@@ -1325,9 +1381,18 @@ def build_features(texts, tfidf=None, scaler=None, fit=False):
1325
  # ───────────────────────────────────────────────────────────
1326
 
1327
 
1328
- def train_model(X_texts, y_labels):
1329
- """Train a Random Forest with hybrid features and cross-validation."""
 
 
 
 
 
 
 
 
1330
  label_names = ["Real", "Fake"]
 
1331
 
1332
  # ── Split data (80/10/10) ──
1333
  print("\nSplitting data (80/10/10)...")
@@ -1362,9 +1427,9 @@ def train_model(X_texts, y_labels):
1362
  print("\nRunning 5-Fold Cross-Validation on training set...")
1363
  rf_cv = RandomForestClassifier(
1364
  n_estimators=500,
1365
- max_depth=20,
1366
  min_samples_split=5,
1367
- min_samples_leaf=3,
1368
  class_weight="balanced",
1369
  n_jobs=-1,
1370
  random_state=42,
@@ -1379,9 +1444,9 @@ def train_model(X_texts, y_labels):
1379
  start_time = time.time()
1380
  rf = RandomForestClassifier(
1381
  n_estimators=500,
1382
- max_depth=20,
1383
  min_samples_split=5,
1384
- min_samples_leaf=3,
1385
  class_weight="balanced",
1386
  n_jobs=-1,
1387
  random_state=42,
@@ -1542,12 +1607,24 @@ def main():
1542
  )
1543
 
1544
  # 2. Preprocess
1545
- # undersample=False: keep ALL real articles β€” RandomForest's class_weight='balanced'
1546
- # already compensates for imbalance without discarding training data.
1547
- X_texts, y_labels = preprocess(df, undersample=False)
 
 
1548
 
1549
  # 3. Train & evaluate
1550
- model, vectorizer, scaler, metrics = train_model(X_texts, y_labels)
 
 
 
 
 
 
 
 
 
 
1551
 
1552
  # 4. Save
1553
  print("\n" + "=" * 60)
 
1000
 
1001
  if cebuano_only:
1002
  # ── Cebuano-only mode ──────────────────────────────────────────────
1003
+ # Real: CebuaNER (capped) | Fake: MT-translated from jcblaise Tagalog fakes
1004
  print(" Mode: CEBUANO-ONLY")
1005
 
1006
+ df_ceb_fake = _augment_fake_news_with_translation(target_lang="ceb")
1007
+ ceb_fake_count = len(df_ceb_fake) if df_ceb_fake is not None else 0
1008
+
1009
  df3 = _load_cebuaner_as_dataframe()
1010
  if df3 is not None:
1011
+ if ceb_fake_count > 0 and len(df3) > ceb_fake_count:
1012
+ print(
1013
+ f" [3] Undersampling CebuaNER real news: "
1014
+ f"{len(df3)} β†’ {ceb_fake_count} (to match {ceb_fake_count} fake articles)"
1015
+ )
1016
+ df3 = df3.sample(n=ceb_fake_count, random_state=42).reset_index(drop=True)
1017
  frames.append(df3)
1018
 
 
1019
  if df_ceb_fake is not None:
1020
  frames.append(df_ceb_fake)
1021
 
1022
  elif tagalog_only:
1023
  # ── Tagalog-only mode ──────────────────────────────────────────────
1024
+ # Fake priority: satire_facebook.csv first (real Filipino social media
1025
+ # fake news), then augmented_tl_fakes.csv to fill the quota.
1026
+ # Real news: BalitaNLP capped to total fake count (undersampling).
1027
+ print(" Mode: TAGALOG-ONLY (per-dataset filtering, undersampled)")
 
1028
 
1029
  # [1] jcblaise β€” labeled Filipino fake-news corpus, load ALL rows
1030
  csv1 = os.path.join(
 
1084
  # [3] CebuaNER β€” skip (Cebuano, not Tagalog)
1085
  print(" [3] josephimperial/CebuaNER: skipped (Tagalog-only mode)")
1086
 
1087
+ # [sat] PRIORITY: satire_facebook.csv β€” real Filipino social-media fake news.
1088
+ # Loaded FIRST so it is always included in the fake quota.
1089
+ tl_satire_count = 0
1090
+ csv_satire = os.path.join(PROJECT_ROOT, "data", "raw", "satire_facebook.csv")
1091
+ if os.path.exists(csv_satire):
1092
+ df_sat = pd.read_csv(csv_satire)
1093
+ if "article" in df_sat.columns:
1094
+ df_sat = df_sat[["article"]].dropna().copy()
1095
+ df_sat = df_sat[df_sat["article"].str.split().str.len() >= 5]
1096
+ df_sat["label"] = 1 # Fake/Satire
1097
+ tl_satire_count = len(df_sat)
1098
+ print(
1099
+ f" [sat] satire_facebook.csv (PRIORITY): "
1100
+ f"{tl_satire_count} Tagalog satire posts (all Fake)"
1101
+ )
1102
+ frames.append(df_sat)
1103
+ else:
1104
+ print(" [sat] satire_facebook.csv not found β€” skipping priority satire.")
1105
 
1106
+ # [aug] MT-translated Tagalog fakes β€” fill remaining quota after satire.
1107
  df_tl_fake = _augment_fake_news_with_translation(target_lang="tl")
1108
  if df_tl_fake is not None:
1109
  frames.append(df_tl_fake)
1110
+ tl_aug_count = len(df_tl_fake) if df_tl_fake is not None else 0
1111
+ total_tl_fake = tl_satire_count + tl_aug_count
1112
+ print(f" Total Tagalog fakes available: {total_tl_fake} ({tl_satire_count} satire + {tl_aug_count} augmented)") # noqa: E501
1113
+
1114
+ # [4] BalitaNLP β€” Tagalog real news, capped to total fake count (undersampling)
1115
+ df4 = _load_balitanlp_as_dataframe()
1116
+ if df4 is not None:
1117
+ if total_tl_fake > 0 and len(df4) > total_tl_fake:
1118
+ print(
1119
+ f" [4] Undersampling BalitaNLP: "
1120
+ f"{len(df4):,} β†’ {total_tl_fake:,} (matching {total_tl_fake} fake articles)"
1121
+ )
1122
+ df4 = df4.sample(n=total_tl_fake, random_state=42).reset_index(drop=True)
1123
+ frames.append(df4)
1124
 
1125
  else:
1126
  # ── Mixed mode: all datasets, no language filter ──
 
1175
  # [4] BalitaNLP β€” skipped in mixed mode (use --tagalog-only to include)
1176
  print(" [4] LanceBunag/BalitaNLP: skipped (use --tagalog-only to include)")
1177
 
1178
+ # [5] Facebook satire posts β€” included in mixed mode only.
1179
+ # In tagalog_only mode, satire is loaded above as [sat] PRIORITY.
1180
+ # In cebuano_only mode, satire is Tagalog/Filipino and not relevant.
1181
+ if not tagalog_only and not cebuano_only:
1182
+ csv_satire = os.path.join(PROJECT_ROOT, "data", "raw", "satire_facebook.csv")
1183
+ if os.path.exists(csv_satire):
1184
+ df_satire = pd.read_csv(csv_satire)
1185
+ if "article" in df_satire.columns:
1186
+ df_satire = df_satire[["article"]].dropna().copy()
1187
+ df_satire = df_satire[df_satire["article"].str.split().str.len() >= 5]
1188
+ df_satire["label"] = 1 # satire = Fake
1189
+ print(
1190
+ f" [5] Facebook satire (BreakingPHMemes): {len(df_satire)} posts "
1191
+ f"(all Fake/Satire)"
1192
+ )
1193
+ frames.append(df_satire)
1194
+ else:
1195
+ print(" [5] satire_facebook.csv not found β€” run scraper/scrape_satire_facebook.py to generate it")
1196
+
1197
  if not frames:
1198
  raise FileNotFoundError(
1199
  "No datasets found! Place at least one dataset in data/raw/."
1200
  )
1201
 
1202
  # ── Merge and deduplicate ──
1203
+
1204
  df = pd.concat(frames, ignore_index=True)
1205
  df = df.dropna(subset=["article"]).copy()
1206
  df = df[df["article"].str.len() > 0].copy()
 
1235
 
1236
  Balancing strategy (applied in order of preference):
1237
  1. oversample=True β€” RandomOverSampler duplicates minority-class (Fake) rows
1238
+ until classes are equal. Used for mixed-language mode
1239
+ where the real:fake ratio can be large.
1240
  2. undersample=True β€” downsample the majority class (legacy fallback).
1241
  Both: oversample is applied first, then undersample (rarely needed).
1242
+ Neither (language-specific modes): real news is already capped to match fake
1243
+ count at load time, so no resampling is needed here.
1244
 
1245
  class_weight='balanced' is ALSO set on the RandomForest, so even without
1246
  resampling the model still penalises fake-news misclassification more.
 
1381
  # ───────────────────────────────────────────────────────────
1382
 
1383
 
1384
+ def train_model(X_texts, y_labels, max_depth=20, min_samples_leaf=3):
1385
+ """Train a Random Forest with hybrid features and cross-validation.
1386
+
1387
+ Args:
1388
+ max_depth (int): Maximum tree depth. Use lower values (8-10) for small or
1389
+ homogeneous datasets (e.g. Cebuano) to prevent memorizing source-format
1390
+ artifacts instead of genuine fake-news signals.
1391
+ min_samples_leaf (int): Minimum samples at a leaf. Higher values (5+) add
1392
+ regularization and reduce overfitting on small datasets.
1393
+ """
1394
  label_names = ["Real", "Fake"]
1395
+ print(f" Hyperparameters: max_depth={max_depth}, min_samples_leaf={min_samples_leaf}")
1396
 
1397
  # ── Split data (80/10/10) ──
1398
  print("\nSplitting data (80/10/10)...")
 
1427
  print("\nRunning 5-Fold Cross-Validation on training set...")
1428
  rf_cv = RandomForestClassifier(
1429
  n_estimators=500,
1430
+ max_depth=max_depth,
1431
  min_samples_split=5,
1432
+ min_samples_leaf=min_samples_leaf,
1433
  class_weight="balanced",
1434
  n_jobs=-1,
1435
  random_state=42,
 
1444
  start_time = time.time()
1445
  rf = RandomForestClassifier(
1446
  n_estimators=500,
1447
+ max_depth=max_depth,
1448
  min_samples_split=5,
1449
+ min_samples_leaf=min_samples_leaf,
1450
  class_weight="balanced",
1451
  n_jobs=-1,
1452
  random_state=42,
 
1607
  )
1608
 
1609
  # 2. Preprocess
1610
+ # For language-specific modes (tagalog/cebuano), real news is already
1611
+ # undersampled to match fake count at load time β€” no oversampling needed.
1612
+ # For mixed mode, classes are imbalanced so oversample=True is kept.
1613
+ lang_specific = args.tagalog_only or args.cebuano_only
1614
+ X_texts, y_labels = preprocess(df, undersample=False, oversample=not lang_specific)
1615
 
1616
  # 3. Train & evaluate
1617
+ # Cebuano-only: reduce model complexity to prevent memorizing source-format
1618
+ # artifacts (machine-translated fakes vs. native CebuaNER text). Lower
1619
+ # max_depth forces the model to use weaker, more-generalizable signals.
1620
+ if args.cebuano_only:
1621
+ model, vectorizer, scaler, metrics = train_model(
1622
+ X_texts, y_labels,
1623
+ max_depth=8,
1624
+ min_samples_leaf=5,
1625
+ )
1626
+ else:
1627
+ model, vectorizer, scaler, metrics = train_model(X_texts, y_labels)
1628
 
1629
  # 4. Save
1630
  print("\n" + "=" * 60)
check_app/pubspec.lock CHANGED
@@ -148,10 +148,10 @@ packages:
148
  dependency: transitive
149
  description:
150
  name: matcher
151
- sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
152
  url: "https://pub.dev"
153
  source: hosted
154
- version: "0.12.18"
155
  material_color_utilities:
156
  dependency: transitive
157
  description:
@@ -321,10 +321,10 @@ packages:
321
  dependency: transitive
322
  description:
323
  name: test_api
324
- sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
325
  url: "https://pub.dev"
326
  source: hosted
327
- version: "0.7.9"
328
  typed_data:
329
  dependency: transitive
330
  description:
 
148
  dependency: transitive
149
  description:
150
  name: matcher
151
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
152
  url: "https://pub.dev"
153
  source: hosted
154
+ version: "0.12.19"
155
  material_color_utilities:
156
  dependency: transitive
157
  description:
 
321
  dependency: transitive
322
  description:
323
  name: test_api
324
+ sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
325
  url: "https://pub.dev"
326
  source: hosted
327
+ version: "0.7.10"
328
  typed_data:
329
  dependency: transitive
330
  description:
check_article.py CHANGED
@@ -170,6 +170,86 @@ def explain_sources(db_results, web_results, top_score):
170
  return lines
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def get_user_input():
174
  """Read multi-line input from user (2 blank lines or 'done' to stop)."""
175
  print("=" * 60)
@@ -433,6 +513,11 @@ def display_results(result):
433
  print(line)
434
  print()
435
 
 
 
 
 
 
436
  # 5. Date Flags (if any)
437
  if date_flags:
438
  print("=" * 60)
 
170
  return lines
171
 
172
 
173
+ def display_pattern_deviation(pd):
174
+ """Display the Pattern Deviation Check section."""
175
+ print("=" * 60)
176
+ print(" PATTERN DEVIATION CHECK")
177
+ print(" Does this article follow the reputable news pattern?")
178
+ print("=" * 60)
179
+
180
+ verdict = pd.get("verdict", "")
181
+ deviation_score = pd.get("deviation_score", 0.0)
182
+ extra_claims = pd.get("extra_claims", [])
183
+ corroborated = pd.get("corroborated_claims", [])
184
+ all_titles = pd.get("all_source_titles", [])
185
+ reliable_count = pd.get("reliable_source_count", 0)
186
+
187
+ if verdict == "NO EXTERNAL SOURCES":
188
+ print(" No external sources available for pattern comparison.")
189
+ print()
190
+ return
191
+
192
+ # External pattern preview
193
+ print(f" Based on {len(all_titles)} external source(s)")
194
+ if reliable_count > 0:
195
+ print(f" ({reliable_count} from known reliable sources):")
196
+ else:
197
+ print(" (no known reliable sources):")
198
+ for title, source in all_titles[:4]:
199
+ src_label = f" [{source}]" if source else ""
200
+ print(f" β€’ \"{title[:90]}\"{src_label}")
201
+ if len(all_titles) > 4:
202
+ print(f" β€’ …and {len(all_titles) - 4} more")
203
+ print()
204
+
205
+ # Corroborated claims
206
+ if corroborated:
207
+ print(f" βœ” Corroborated ({len(corroborated)} sentence(s) align with external sources):")
208
+ for s in corroborated[:2]:
209
+ print(f" β†’ \"{s[:100]}{'...' if len(s) > 100 else ''}\"")
210
+ print()
211
+
212
+ # Extra claims (deviations)
213
+ if extra_claims:
214
+ print(f" ⚠️ Extra claims not found in reputable sources ({len(extra_claims)} sentence(s)):")
215
+ for s in extra_claims[:4]:
216
+ print(f" β†’ \"{s[:100]}{'...' if len(s) > 100 else ''}\"")
217
+ if len(extra_claims) > 4:
218
+ print(f" …and {len(extra_claims) - 4} more")
219
+ print()
220
+ else:
221
+ print(" βœ” No extra claims detected β€” article stays within the reported facts.")
222
+ print()
223
+
224
+ # Deviation score bar
225
+ bar_filled = int(deviation_score * 20)
226
+ bar = "β–ˆ" * bar_filled + "β–‘" * (20 - bar_filled)
227
+ print(f" Deviation score: {deviation_score:.0%} [{bar}]")
228
+
229
+ # Verdict with icon
230
+ VERDICT_ICONS = {
231
+ "FOLLOWS PATTERN": "βœ…",
232
+ "MINOR DEVIATION": "🟑",
233
+ "SIGNIFICANT DEVIATION": "🟠",
234
+ }
235
+ icon = VERDICT_ICONS.get(verdict, "⚠️")
236
+ print(f" Verdict: {icon} {verdict}")
237
+
238
+ if verdict == "FOLLOWS PATTERN":
239
+ print(" ➀ The article's claims are consistent with what reputable sources report.")
240
+ elif verdict == "MINOR DEVIATION":
241
+ print(
242
+ " ➀ The article mostly follows reputable reporting but includes "
243
+ "some claims that could not be corroborated externally. Verify those claims."
244
+ )
245
+ elif verdict == "SIGNIFICANT DEVIATION":
246
+ print(
247
+ " ➀ BIAS SIGNAL: The article adds substantial claims not found in "
248
+ "any reputable external source. These additions are the likely source of bias."
249
+ )
250
+ print()
251
+
252
+
253
  def get_user_input():
254
  """Read multi-line input from user (2 blank lines or 'done' to stop)."""
255
  print("=" * 60)
 
513
  print(line)
514
  print()
515
 
516
+ # Pattern Deviation Check
517
+ pattern_deviation = result.get("pattern_deviation")
518
+ if pattern_deviation and pattern_deviation.get("verdict") != "NO EXTERNAL SOURCES":
519
+ display_pattern_deviation(pattern_deviation)
520
+
521
  # 5. Date Flags (if any)
522
  if date_flags:
523
  print("=" * 60)
checker/external/pattern_deviation.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pattern Deviation Analyzer
3
+ ===========================
4
+ Implements the student journalist mental model for bias detection:
5
+
6
+ 1. Build a "reputable pattern" from external news titles/snippets
7
+ (Google News + local DB results from reliable sources).
8
+ 2. Compare every article sentence against that pattern.
9
+ 3. Sentences whose concepts are NOT present in ANY reputable source
10
+ are surfaced as "extra claims" β€” possible bias signals.
11
+
12
+ No new dependencies β€” uses the MiniLM model already loaded by the project
13
+ (sentence_transformers) for semantic similarity.
14
+ """
15
+
16
+ import re
17
+ from typing import List, Dict, Any, Tuple
18
+
19
+ # Reliable source identifiers (same list as check_article.py)
20
+ RELIABLE_SOURCES = {
21
+ "inquirer", "philstar", "manila bulletin", "abs-cbn",
22
+ "cnn philippines", "gma", "rappler", "sunstar",
23
+ "businessmirror", "pna", "philippine news agency",
24
+ "bbc", "reuters", "ap", "associated press",
25
+ "new york times", "the guardian",
26
+ }
27
+
28
+ # Thresholds
29
+ SENTENCE_SIMILARITY_THRESHOLD = 0.65 # Below this β†’ sentence not in external pattern (raised from 0.40 as MiniLM baseline is high)
30
+ DEVIATION_MINOR_THRESHOLD = 0.30 # deviation_score >= this β†’ MINOR DEVIATION
31
+ DEVIATION_SIGNIFICANT_THRESHOLD = 0.55 # deviation_score >= this β†’ SIGNIFICANT DEVIATION
32
+
33
+ # Cap how many sentences we analyze (performance guard for long articles)
34
+ MAX_SENTENCES = 20
35
+
36
+
37
+ def _is_reliable(source: str) -> bool:
38
+ src = source.lower()
39
+ return any(r in src for r in RELIABLE_SOURCES)
40
+
41
+
42
+ def _split_sentences(text: str) -> List[str]:
43
+ """Naively split text into sentences on . ! ? boundaries."""
44
+ raw = re.split(r"(?<=[.!?])\s+", text.strip())
45
+ # Filter very short fragments (less than 5 words)
46
+ return [s.strip() for s in raw if len(s.split()) >= 5]
47
+
48
+
49
+ def build_external_pattern(web_results: List[Dict], db_results: List[Dict]) -> str:
50
+ """
51
+ Concatenate titles from reliable web and DB sources into a single
52
+ 'pattern string' representing what reputable journalism says on this topic.
53
+
54
+ Returns:
55
+ str: Combined pattern text, or empty string if no reliable sources found.
56
+ """
57
+ fragments = []
58
+
59
+ for r in web_results:
60
+ source = r.get("source", "")
61
+ title = r.get("title", "").strip()
62
+ if title and _is_reliable(source):
63
+ fragments.append(title)
64
+
65
+ for r in db_results:
66
+ source = r.get("source", "")
67
+ title = r.get("title", "").strip()
68
+ if title and _is_reliable(source):
69
+ fragments.append(title)
70
+
71
+ # Fall back to ALL sources (reliable or not) if none found
72
+ if not fragments:
73
+ for r in web_results + db_results:
74
+ title = r.get("title", "").strip()
75
+ if title:
76
+ fragments.append(title)
77
+
78
+ return " | ".join(fragments)
79
+
80
+
81
+ def _get_minilm():
82
+ """Lazy import of MiniLM to avoid circular imports."""
83
+ from checker.internal.core import get_minilm_model
84
+ return get_minilm_model()
85
+
86
+
87
+ def _cosine(a, b) -> float:
88
+ """Compute cosine similarity between two numpy vectors."""
89
+ import numpy as np
90
+ norm_a = float(np.linalg.norm(a))
91
+ norm_b = float(np.linalg.norm(b))
92
+ if norm_a == 0 or norm_b == 0:
93
+ return 0.0
94
+ return float(np.dot(a, b) / (norm_a * norm_b))
95
+
96
+
97
+ def compare_to_external_pattern(
98
+ article_text: str,
99
+ web_results: List[Dict],
100
+ db_results: List[Dict] = None,
101
+ ) -> Dict[str, Any]:
102
+ """
103
+ Compare the article's sentences against the external reputable pattern.
104
+
105
+ For each article sentence, measure the maximum cosine similarity to the
106
+ external pattern string (encoded as a single MiniLM embedding). Sentences
107
+ below SENTENCE_SIMILARITY_THRESHOLD are flagged as 'extra claims' β€” content
108
+ that does not appear in any reputable external source.
109
+
110
+ Args:
111
+ article_text (str): The full article or claim to analyze.
112
+ web_results (list): Google News results (dicts with 'title', 'source').
113
+ db_results (list): Local DB results (dicts with 'title', 'source').
114
+
115
+ Returns:
116
+ dict with keys:
117
+ - external_pattern (str): Summary of what reputable sources say.
118
+ - extra_claims (list[str]): Sentences not found in external pattern.
119
+ - corroborated_claims (list[str]): Sentences aligned with pattern.
120
+ - deviation_score (float): 0–1 fraction of sentences that deviate.
121
+ - verdict (str): "FOLLOWS PATTERN" | "MINOR DEVIATION" | "SIGNIFICANT DEVIATION"
122
+ - reliable_source_count (int): Number of reliable sources used.
123
+ - all_source_titles (list[str]): All titles from external sources.
124
+ """
125
+ if db_results is None:
126
+ db_results = []
127
+
128
+ # ── Build external pattern ──────────────────────────────────────────────
129
+ external_pattern = build_external_pattern(web_results, db_results)
130
+
131
+ # Collect all titles for display
132
+ all_titles = []
133
+ for r in web_results + db_results:
134
+ t = r.get("title", "").strip()
135
+ if t:
136
+ all_titles.append((t, r.get("source", "")))
137
+
138
+ reliable_count = sum(1 for r in web_results + db_results if _is_reliable(r.get("source", "")))
139
+
140
+ # If no external sources at all, can't compare
141
+ if not external_pattern.strip():
142
+ return {
143
+ "external_pattern": "",
144
+ "extra_claims": [],
145
+ "corroborated_claims": [],
146
+ "deviation_score": 0.0,
147
+ "verdict": "NO EXTERNAL SOURCES",
148
+ "reliable_source_count": 0,
149
+ "all_source_titles": [],
150
+ }
151
+
152
+ # ── Split article into sentences ────────────────────────────────────────
153
+ sentences = _split_sentences(article_text)[:MAX_SENTENCES]
154
+
155
+ if not sentences:
156
+ return {
157
+ "external_pattern": external_pattern,
158
+ "extra_claims": [],
159
+ "corroborated_claims": [],
160
+ "deviation_score": 0.0,
161
+ "verdict": "FOLLOWS PATTERN",
162
+ "reliable_source_count": reliable_count,
163
+ "all_source_titles": all_titles,
164
+ }
165
+
166
+ # ── Embed all sentences + pattern in one batched call ──────────────────
167
+ try:
168
+ minilm = _get_minilm()
169
+ all_texts = sentences + [external_pattern]
170
+ embeddings = minilm.encode(all_texts, batch_size=64, show_progress_bar=False)
171
+ sentence_embeddings = embeddings[:len(sentences)]
172
+ pattern_embedding = embeddings[-1]
173
+ except Exception:
174
+ # Graceful degradation β€” can't run MiniLM, return neutral result
175
+ return {
176
+ "external_pattern": external_pattern,
177
+ "extra_claims": [],
178
+ "corroborated_claims": list(sentences),
179
+ "deviation_score": 0.0,
180
+ "verdict": "FOLLOWS PATTERN",
181
+ "reliable_source_count": reliable_count,
182
+ "all_source_titles": all_titles,
183
+ }
184
+
185
+ # ── Classify each sentence ──────────────────────────────────────────────
186
+ extra_claims: List[str] = []
187
+ corroborated_claims: List[str] = []
188
+
189
+ for sent, emb in zip(sentences, sentence_embeddings):
190
+ sim = _cosine(emb, pattern_embedding)
191
+ if sim < SENTENCE_SIMILARITY_THRESHOLD:
192
+ extra_claims.append(sent)
193
+ else:
194
+ corroborated_claims.append(sent)
195
+
196
+ # ── Compute deviation score ─────────────────────────────────────────────
197
+ total = len(sentences)
198
+ deviation_score = round(len(extra_claims) / total, 3) if total > 0 else 0.0
199
+
200
+ # ── Determine verdict ───────────────────────────────────────────────────
201
+ if deviation_score >= DEVIATION_SIGNIFICANT_THRESHOLD:
202
+ verdict = "SIGNIFICANT DEVIATION"
203
+ elif deviation_score >= DEVIATION_MINOR_THRESHOLD:
204
+ verdict = "MINOR DEVIATION"
205
+ else:
206
+ verdict = "FOLLOWS PATTERN"
207
+
208
+ return {
209
+ "external_pattern": external_pattern,
210
+ "extra_claims": extra_claims,
211
+ "corroborated_claims": corroborated_claims,
212
+ "deviation_score": deviation_score,
213
+ "verdict": verdict,
214
+ "reliable_source_count": reliable_count,
215
+ "all_source_titles": all_titles,
216
+ }
checker/fact_checker.py CHANGED
@@ -25,6 +25,7 @@ from checker.internal.core import (
25
  )
26
  from checker.external.core import ExternalChecker
27
  from checker.external.claim_verifier import check_claims
 
28
  from scipy.sparse import hstack, csr_matrix
29
 
30
 
@@ -74,45 +75,56 @@ class FactChecker:
74
  # Use cleaned text for pipeline, but keep original for reference
75
  analysis_text = cleaned_for_analysis if cleaned_for_analysis else text
76
 
77
- # 2. Internal check (ML model + bias)
78
- internal_result = self.internal.check(analysis_text)
79
-
80
- # 3. External check (DB + web)
81
  # Use headline for external search if provided β€” headlines make much
82
  # better search queries than full article bodies.
83
  search_query = headline if headline else analysis_text
84
  external_result = self.external.check_claim(search_query)
85
 
86
- # 4. Extract time-orientation scores from stylometric features
 
 
 
 
 
 
 
 
 
 
 
 
87
  stylo = extract_stylometric_features(clean_text(analysis_text))
88
  present_focus = stylo[23] if len(stylo) > 23 else 0.0
89
 
90
- # 5. Date validation
91
  article_date, date_flags = self._validate_dates(external_result, present_focus)
92
 
93
- # 6. Combine verdicts (with date and obfuscation awareness)
94
  obfuscated = internal_result.get("bias", {}).get("obfuscated_text", False)
95
  final_verdict, final_details = self._combine_verdicts(
96
- internal_result, external_result, date_flags, obfuscated=obfuscated
 
97
  )
98
 
99
- # 7. Classify misinformation type using existing signals
100
  obfuscated = internal_result.get("bias", {}).get("obfuscated_text", False)
101
  misinfo_type = self._classify_misinfo_type(
102
- internal_result, external_result, date_flags, obfuscated=obfuscated
 
103
  )
104
 
105
- # 8. LIME explanation (why the ML model decided Real or Fake)
106
  # Use cleaned text so URL fragments don't appear as contributing words
107
  lime_samples = int(os.environ.get("LIME_SAMPLES", 100))
108
  lime_explanation = self.explain(analysis_text, num_samples=lime_samples)
109
 
110
- # 9. Headline-body consistency check (if headline provided)
111
  headline_analysis = None
112
  if headline and len(analysis_text) > len(headline) + 20:
113
  headline_analysis = self._check_headline_consistency(headline, analysis_text)
114
 
115
- # 10. Lightweight claim verification (regex + existing Google News search)
116
  web_results = external_result.get("web_results", [])
117
  topic_context = web_results[0]["title"] if web_results else ""
118
  claim_verification = check_claims(text, topic_context=topic_context)
@@ -132,6 +144,7 @@ class FactChecker:
132
  "lime_explanation": lime_explanation,
133
  "misinformation_type": misinfo_type,
134
  "claim_verification": claim_verification,
 
135
  }
136
  if headline_analysis:
137
  result["headline_analysis"] = headline_analysis
@@ -312,7 +325,7 @@ class FactChecker:
312
  )
313
  return article_date_str, date_flags
314
 
315
- def _classify_misinfo_type(self, internal, external, date_flags, obfuscated=False):
316
  """
317
  Classify the type of misinformation using existing pipeline signals.
318
 
@@ -356,37 +369,56 @@ class FactChecker:
356
  "(βˆ†, leet-speak, ALL-CAPS) typical of sensationalized or manipulated social media posts."
357
  )
358
 
359
- # Priority 1: Old news being shared as current
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  if is_old_news:
361
  return "🚩 Old News Recirculated β€” Real event, but being shared as if it's current news"
362
 
363
- # Priority 2: Out of context (real story, old source, not ML-flagged as fake)
364
  if has_date_flag and has_external and ml_verdict != "Fake":
365
  return "🟑 Out of Context β€” The referenced event is real but may be misrepresenting its timeframe or circumstances"
366
 
367
- # Priority 3: Misleading headline (external story exists, but ML flags content as fake)
368
  if has_external and ml_verdict == "Fake":
369
  return "🟠 Misleading Headline β€” A real event exists, but this version appears to distort or misrepresent the facts"
370
 
371
- # Priority 4: Fabricated (high-confidence fake, no external coverage at all)
372
  if ml_verdict == "Fake" and ml_confidence >= 0.70 and not has_external:
373
  return "πŸ”΄ Fabricated β€” No credible coverage found and ML model flags content as likely fabricated"
374
 
375
- # Priority 5: Soft fake signal but no external coverage
376
  if ml_verdict == "Fake" and not has_external:
377
  return "πŸ”΄ Likely Fabricated β€” No external coverage found; ML model suggests this may be fabricated (moderate confidence)"
378
 
379
- # Priority 6: Verified/strong external match with no fake signal = clean
380
  if ext_verdict == "VERIFIED":
381
  return "βœ… No misinformation detected β€” Article verified by matching news sources"
382
 
383
- # Priority 7: Unverified claim (no external coverage, ML not confident either way)
384
  if ext_verdict == "UNVERIFIED":
385
  return "⚠️ Unverifiable β€” No matching coverage found to confirm or deny this claim"
386
 
387
  return "βœ… No misinformation type detected"
388
 
389
- def _combine_verdicts(self, internal, external, date_flags=None, obfuscated=False):
390
  """
391
  Combine internal ML verdict with external source verification.
392
 
@@ -435,26 +467,54 @@ class FactChecker:
435
  ml_confidence = max(0.0, ml_confidence - SOURCE_PENALTY)
436
 
437
  # ── Build verdict and details ──
 
 
 
 
 
 
438
  if ext_verdict == "VERIFIED":
439
- verdict = "VERIFIED"
440
- details = (
441
- f"This article matches verified news sources "
442
- f"(similarity: {top_score:.0%})."
443
- )
 
 
 
 
 
 
 
 
 
444
  if source_count > 1:
445
  details += f" {source_count} corroborating sources found."
446
  elif ext_verdict == "COVERAGE_FOUND_ONLINE":
447
- verdict = "COVERAGE FOUND ONLINE"
448
- source_word = "source" if len(web_results) == 1 else "sources"
449
- details = (
450
- f"Covered by {len(web_results)} online news {source_word}. "
451
- "Review the related sources below to verify."
452
- )
 
 
 
 
 
 
 
 
 
 
453
  elif ext_verdict == "WEAK_MATCH":
454
  verdict = "WEAK MATCH"
455
  details = "Some loosely related articles were found, but no strong match. Review carefully."
456
  else:
457
  # No external sources β€” rely on internal ML model
 
 
 
458
  if ml_verdict == "Fake":
459
  verdict = "LIKELY FAKE"
460
  details = (
 
25
  )
26
  from checker.external.core import ExternalChecker
27
  from checker.external.claim_verifier import check_claims
28
+ from checker.external.pattern_deviation import compare_to_external_pattern
29
  from scipy.sparse import hstack, csr_matrix
30
 
31
 
 
75
  # Use cleaned text for pipeline, but keep original for reference
76
  analysis_text = cleaned_for_analysis if cleaned_for_analysis else text
77
 
78
+ # 2. External check (DB + web) β€” must run before pattern deviation
 
 
 
79
  # Use headline for external search if provided β€” headlines make much
80
  # better search queries than full article bodies.
81
  search_query = headline if headline else analysis_text
82
  external_result = self.external.check_claim(search_query)
83
 
84
+ # 3. Pattern deviation β€” runs FIRST before internal bias analysis so its
85
+ # result can be fed directly into analyze_bias() as a primary signal.
86
+ pattern_deviation = compare_to_external_pattern(
87
+ analysis_text,
88
+ web_results=external_result.get("web_results", []),
89
+ db_results=external_result.get("db_results", []),
90
+ )
91
+
92
+ # 4. Internal check (ML model + bias), pattern_deviation passed in so
93
+ # bias_analyzer can weight deviating articles as more sensational/biased.
94
+ internal_result = self.internal.check(analysis_text, pattern_deviation=pattern_deviation)
95
+
96
+ # 5. Extract time-orientation scores from stylometric features
97
  stylo = extract_stylometric_features(clean_text(analysis_text))
98
  present_focus = stylo[23] if len(stylo) > 23 else 0.0
99
 
100
+ # 6. Date validation
101
  article_date, date_flags = self._validate_dates(external_result, present_focus)
102
 
103
+ # 7. Combine verdicts (pattern_deviation weighted more heavily)
104
  obfuscated = internal_result.get("bias", {}).get("obfuscated_text", False)
105
  final_verdict, final_details = self._combine_verdicts(
106
+ internal_result, external_result, date_flags,
107
+ obfuscated=obfuscated, pattern_deviation=pattern_deviation
108
  )
109
 
110
+ # 8. Classify misinformation type using existing signals
111
  obfuscated = internal_result.get("bias", {}).get("obfuscated_text", False)
112
  misinfo_type = self._classify_misinfo_type(
113
+ internal_result, external_result, date_flags,
114
+ obfuscated=obfuscated, pattern_deviation=pattern_deviation
115
  )
116
 
117
+ # 9. LIME explanation (why the ML model decided Real or Fake)
118
  # Use cleaned text so URL fragments don't appear as contributing words
119
  lime_samples = int(os.environ.get("LIME_SAMPLES", 100))
120
  lime_explanation = self.explain(analysis_text, num_samples=lime_samples)
121
 
122
+ # 10. Headline-body consistency check (if headline provided)
123
  headline_analysis = None
124
  if headline and len(analysis_text) > len(headline) + 20:
125
  headline_analysis = self._check_headline_consistency(headline, analysis_text)
126
 
127
+ # 11. Lightweight claim verification (regex + existing Google News search)
128
  web_results = external_result.get("web_results", [])
129
  topic_context = web_results[0]["title"] if web_results else ""
130
  claim_verification = check_claims(text, topic_context=topic_context)
 
144
  "lime_explanation": lime_explanation,
145
  "misinformation_type": misinfo_type,
146
  "claim_verification": claim_verification,
147
+ "pattern_deviation": pattern_deviation,
148
  }
149
  if headline_analysis:
150
  result["headline_analysis"] = headline_analysis
 
325
  )
326
  return article_date_str, date_flags
327
 
328
+ def _classify_misinfo_type(self, internal, external, date_flags, obfuscated=False, pattern_deviation=None):
329
  """
330
  Classify the type of misinformation using existing pipeline signals.
331
 
 
369
  "(βˆ†, leet-speak, ALL-CAPS) typical of sensationalized or manipulated social media posts."
370
  )
371
 
372
+ # Priority 1: Pattern deviation β€” promoted above old-news check because
373
+ # an article that adds claims not in ANY reputable source is a stronger
374
+ # and more specific signal than recirculated date information.
375
+ if pattern_deviation and pattern_deviation.get("verdict") == "SIGNIFICANT DEVIATION":
376
+ if has_external:
377
+ return (
378
+ "🟠 Added Claims / Spin β€” A real event is covered by reputable sources, "
379
+ "but this version adds claims not corroborated by any of them"
380
+ )
381
+ else:
382
+ return (
383
+ "πŸ”΄ Unverifiable Added Claims β€” This article makes specific claims "
384
+ "that do not appear in any reputable source found online"
385
+ )
386
+
387
+ if pattern_deviation and pattern_deviation.get("verdict") == "MINOR DEVIATION":
388
+ if not has_external:
389
+ return "🟑 Possible Spin β€” Article makes claims not confirmed by any reputable coverage found"
390
+
391
+ # Priority 2: Old news being shared as current
392
  if is_old_news:
393
  return "🚩 Old News Recirculated β€” Real event, but being shared as if it's current news"
394
 
395
+ # Priority 3: Out of context (real story, old source, not ML-flagged as fake)
396
  if has_date_flag and has_external and ml_verdict != "Fake":
397
  return "🟑 Out of Context β€” The referenced event is real but may be misrepresenting its timeframe or circumstances"
398
 
399
+ # Priority 4: Misleading headline (external story exists, but ML flags content as fake)
400
  if has_external and ml_verdict == "Fake":
401
  return "🟠 Misleading Headline β€” A real event exists, but this version appears to distort or misrepresent the facts"
402
 
403
+ # Priority 5: Fabricated (high-confidence fake, no external coverage at all)
404
  if ml_verdict == "Fake" and ml_confidence >= 0.70 and not has_external:
405
  return "πŸ”΄ Fabricated β€” No credible coverage found and ML model flags content as likely fabricated"
406
 
407
+ # Priority 6: Soft fake signal but no external coverage
408
  if ml_verdict == "Fake" and not has_external:
409
  return "πŸ”΄ Likely Fabricated β€” No external coverage found; ML model suggests this may be fabricated (moderate confidence)"
410
 
411
+ # Priority 7: Verified/strong external match with no fake signal = clean
412
  if ext_verdict == "VERIFIED":
413
  return "βœ… No misinformation detected β€” Article verified by matching news sources"
414
 
415
+ # Priority 8: Unverified claim (no external coverage, ML not confident either way)
416
  if ext_verdict == "UNVERIFIED":
417
  return "⚠️ Unverifiable β€” No matching coverage found to confirm or deny this claim"
418
 
419
  return "βœ… No misinformation type detected"
420
 
421
+ def _combine_verdicts(self, internal, external, date_flags=None, obfuscated=False, pattern_deviation=None):
422
  """
423
  Combine internal ML verdict with external source verification.
424
 
 
467
  ml_confidence = max(0.0, ml_confidence - SOURCE_PENALTY)
468
 
469
  # ── Build verdict and details ──
470
+ # Check pattern deviation before issuing final VERIFIED β€” high deviation
471
+ # means the article adds extra claims beyond what sources report.
472
+ pd_verdict = (pattern_deviation or {}).get("verdict", "")
473
+ pd_score = (pattern_deviation or {}).get("deviation_score", 0.0)
474
+ significant_deviation = pd_verdict == "SIGNIFICANT DEVIATION"
475
+
476
  if ext_verdict == "VERIFIED":
477
+ if significant_deviation:
478
+ # Downgrade: story exists but article adds unreported claims
479
+ verdict = "COVERAGE FOUND β€” PATTERN DEVIATION"
480
+ details = (
481
+ f"This topic is covered by reputable sources (similarity: {top_score:.0%}), "
482
+ f"but the article adds claims not found in those sources "
483
+ f"(deviation score: {pd_score:.0%})."
484
+ )
485
+ else:
486
+ verdict = "VERIFIED"
487
+ details = (
488
+ f"This article matches verified news sources "
489
+ f"(similarity: {top_score:.0%})."
490
+ )
491
  if source_count > 1:
492
  details += f" {source_count} corroborating sources found."
493
  elif ext_verdict == "COVERAGE_FOUND_ONLINE":
494
+ if significant_deviation:
495
+ # Also downgrade online coverage when deviation is significant
496
+ verdict = "BIASED COVERAGE β€” PATTERN DEVIATION"
497
+ source_word = "source" if len(web_results) == 1 else "sources"
498
+ details = (
499
+ f"Found {len(web_results)} online news {source_word}, but the article "
500
+ f"adds claims not present in those sources (deviation score: {pd_score:.0%}). "
501
+ "This article may be spinning or exaggerating a real event."
502
+ )
503
+ else:
504
+ verdict = "COVERAGE FOUND ONLINE"
505
+ source_word = "source" if len(web_results) == 1 else "sources"
506
+ details = (
507
+ f"Covered by {len(web_results)} online news {source_word}. "
508
+ "Review the related sources below to verify."
509
+ )
510
  elif ext_verdict == "WEAK_MATCH":
511
  verdict = "WEAK MATCH"
512
  details = "Some loosely related articles were found, but no strong match. Review carefully."
513
  else:
514
  # No external sources β€” rely on internal ML model
515
+ # Apply additional confidence penalty when deviation is high but ML says Real
516
+ if significant_deviation and ml_verdict == "Real":
517
+ ml_confidence = max(0.0, ml_confidence - 0.10)
518
  if ml_verdict == "Fake":
519
  verdict = "LIKELY FAKE"
520
  details = (
checker/internal/bias_analyzer.py CHANGED
@@ -1245,21 +1245,30 @@ def _score_political_figures(text_lower):
1245
  # ── Main function ─────────────────────────────────────────────────────────────
1246
 
1247
 
1248
- def analyze_bias(text):
1249
  """Analyze a news article for political bias and subjectivity.
1250
 
 
 
 
 
 
 
 
1251
  Returns:
1252
  dict with keys:
1253
- leaning: "Conservative", "Admin/Right", "Left-leaning", or "Centrist"
1254
- is_biased: bool β€” True if clearly leaning or sensational
1255
- unsourced_claim: bool β€” True if article appears to be a blind item / chismis
1256
- unsourced_score: int β€” raw unsourced claim score
1257
- subjectivity: float (0-1)
1258
- subjectivity_flag: bool
1259
- vader_compound: float
1260
- vader_biased: bool
1261
- confidence: float (0-1)
1262
- details: dict with full breakdown
 
 
1263
  """
1264
  if not text or not isinstance(text, str) or len(text.strip()) < 20:
1265
  return {
@@ -1327,6 +1336,20 @@ def analyze_bias(text):
1327
  if vader_biased:
1328
  sensationalism_score += 3
1329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1330
  # ── Determine leaning ──
1331
  total_political = right_score + left_score
1332
  bias_threshold = 3 # raised slightly since we now have more signal sources
@@ -1344,11 +1367,11 @@ def analyze_bias(text):
1344
  else:
1345
  leaning = "Admin/Right"
1346
  diff = right_score - left_score
1347
- confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02)
1348
  elif left_score > right_score:
1349
  leaning = "Left-leaning"
1350
  diff = left_score - right_score
1351
- confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02)
1352
  else:
1353
  leaning = "Centrist"
1354
  confidence = 0.45 if sensationalism_score >= 4 else 0.7
@@ -1374,6 +1397,8 @@ def analyze_bias(text):
1374
  "vader_compound": round(vader_compound, 4),
1375
  "vader_biased": vader_biased,
1376
  "confidence": round(min(confidence, 1.0), 4),
 
 
1377
  "details": {
1378
  "left_keywords": left_hits,
1379
  "right_keywords": right_hits,
@@ -1390,5 +1415,7 @@ def analyze_bias(text):
1390
  "liberal_score": liberal_score,
1391
  "evidence_score": evidence_score,
1392
  "sensationalism_score": sensationalism_score,
 
 
1393
  },
1394
  }
 
1245
  # ── Main function ─────────────────────────────────────────────────────────────
1246
 
1247
 
1248
+ def analyze_bias(text, pattern_deviation=None):
1249
  """Analyze a news article for political bias and subjectivity.
1250
 
1251
+ Args:
1252
+ text (str): Article text.
1253
+ pattern_deviation (dict|None): Result from compare_to_external_pattern().
1254
+ When provided, deviation from reputable sources directly boosts
1255
+ sensationalism score and leaning confidence β€” making pattern deviation
1256
+ a primary signal rather than a post-hoc annotation.
1257
+
1258
  Returns:
1259
  dict with keys:
1260
+ leaning: "Conservative", "Admin/Right", "Left-leaning", or "Centrist"
1261
+ is_biased: bool β€” True if clearly leaning or sensational
1262
+ unsourced_claim: bool β€” True if article appears to be a blind item / chismis
1263
+ unsourced_score: int β€” raw unsourced claim score
1264
+ subjectivity: float (0-1)
1265
+ subjectivity_flag: bool
1266
+ vader_compound: float
1267
+ vader_biased: bool
1268
+ confidence: float (0-1)
1269
+ pattern_deviation_verdict: str β€” "FOLLOWS PATTERN", "MINOR DEVIATION", "SIGNIFICANT DEVIATION", or ""
1270
+ deviation_score: float β€” 0.0–1.0 fraction of sentences not in reputable sources
1271
+ details: dict with full breakdown
1272
  """
1273
  if not text or not isinstance(text, str) or len(text.strip()) < 20:
1274
  return {
 
1336
  if vader_biased:
1337
  sensationalism_score += 3
1338
 
1339
+ # ── Pattern deviation boost (primary signal when available) ────────────────
1340
+ # Deviation from reputable external sources is treated as a strong bias
1341
+ # indicator: articles that add claims not found in any credible source are
1342
+ # more likely to be biased or sensationalized, regardless of keyword matches.
1343
+ pd_verdict = (pattern_deviation or {}).get("verdict", "")
1344
+ pd_score = (pattern_deviation or {}).get("deviation_score", 0.0)
1345
+ pd_confidence_boost = 0.0
1346
+
1347
+ if pd_verdict == "SIGNIFICANT DEVIATION":
1348
+ sensationalism_score += 3 # strong signal: adds claims no credible source reports
1349
+ pd_confidence_boost = 0.08 # more certain about the detected leaning
1350
+ elif pd_verdict == "MINOR DEVIATION":
1351
+ sensationalism_score += 1 # mild signal
1352
+
1353
  # ── Determine leaning ──
1354
  total_political = right_score + left_score
1355
  bias_threshold = 3 # raised slightly since we now have more signal sources
 
1367
  else:
1368
  leaning = "Admin/Right"
1369
  diff = right_score - left_score
1370
+ confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02 + pd_confidence_boost)
1371
  elif left_score > right_score:
1372
  leaning = "Left-leaning"
1373
  diff = left_score - right_score
1374
+ confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02 + pd_confidence_boost)
1375
  else:
1376
  leaning = "Centrist"
1377
  confidence = 0.45 if sensationalism_score >= 4 else 0.7
 
1397
  "vader_compound": round(vader_compound, 4),
1398
  "vader_biased": vader_biased,
1399
  "confidence": round(min(confidence, 1.0), 4),
1400
+ "pattern_deviation_verdict": pd_verdict,
1401
+ "deviation_score": round(pd_score, 4),
1402
  "details": {
1403
  "left_keywords": left_hits,
1404
  "right_keywords": right_hits,
 
1415
  "liberal_score": liberal_score,
1416
  "evidence_score": evidence_score,
1417
  "sensationalism_score": sensationalism_score,
1418
+ "pattern_deviation_verdict": pd_verdict,
1419
+ "deviation_score": round(pd_score, 4),
1420
  },
1421
  }
checker/internal/core.py CHANGED
@@ -460,11 +460,19 @@ class InternalChecker:
460
 
461
  # ── Public interface ──────────────────────────────────────────────
462
 
463
- def check(self, text):
464
- """Run validation, bias analysis, and journalism structure check."""
 
 
 
 
 
 
 
 
465
  return {
466
  "validation": self._validate(text),
467
- "bias": analyze_bias(text),
468
  "structure": analyze_structure(text),
469
  }
470
 
 
460
 
461
  # ── Public interface ──────────────────────────────────────────────
462
 
463
+ def check(self, text, pattern_deviation=None):
464
+ """Run validation, bias analysis, and journalism structure check.
465
+
466
+ Args:
467
+ text (str): Article text to analyze.
468
+ pattern_deviation (dict|None): Pre-computed pattern deviation result
469
+ from compare_to_external_pattern(). When provided, it is passed
470
+ directly into analyze_bias() so deviation from reputable sources
471
+ acts as a primary bias signal rather than a post-hoc annotation.
472
+ """
473
  return {
474
  "validation": self._validate(text),
475
+ "bias": analyze_bias(text, pattern_deviation=pattern_deviation),
476
  "structure": analyze_structure(text),
477
  }
478
 
scraper/collect_satire_manual.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Manual Satire Post Collector
3
+ ==============================
4
+ Since automated Facebook scraping is unreliable, this script lets you
5
+ manually paste posts one-by-one (or in batches) and saves them to the
6
+ same satire_facebook.csv that train.py expects.
7
+
8
+ Usage:
9
+ python scraper/collect_satire_manual.py
10
+
11
+ Just open the Facebook page, copy the post text, paste it here, press Enter twice.
12
+ Type 'done' when finished. Type 'quit' to exit without saving.
13
+ """
14
+
15
+ import os
16
+ import csv
17
+
18
+ OUTPUT_PATH = os.path.join(
19
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
20
+ "data", "raw", "satire_facebook.csv"
21
+ )
22
+ MIN_WORD_COUNT = 10
23
+
24
+
25
+ def get_multiline_input(prompt=""):
26
+ """Read a multi-line paste. Two blank lines or 'done' finishes input."""
27
+ if prompt:
28
+ print(prompt)
29
+ lines = []
30
+ blank_streak = 0
31
+ while True:
32
+ try:
33
+ line = input()
34
+ except EOFError:
35
+ break
36
+ stripped = line.strip().lower()
37
+ if stripped == "quit":
38
+ return None
39
+ if stripped == "done" and not lines:
40
+ return "DONE"
41
+ if line == "":
42
+ blank_streak += 1
43
+ if blank_streak >= 2:
44
+ break
45
+ else:
46
+ blank_streak = 0
47
+ lines.append(line)
48
+ return " ".join(lines).strip()
49
+
50
+
51
+ def save(posts, path):
52
+ exists = os.path.exists(path)
53
+ os.makedirs(os.path.dirname(path), exist_ok=True)
54
+ with open(path, "a", newline="", encoding="utf-8") as f:
55
+ writer = csv.DictWriter(f, fieldnames=["article", "label"])
56
+ if not exists:
57
+ writer.writeheader()
58
+ writer.writerows(posts)
59
+
60
+
61
+ def main():
62
+ print("=" * 58)
63
+ print(" MANUAL SATIRE POST COLLECTOR β€” BreakingPHMemes")
64
+ print("=" * 58)
65
+ print()
66
+ print("Open https://www.facebook.com/BreakingPHMemes in your browser.")
67
+ print("Copy a post's text, paste it below, then press Enter twice.")
68
+ print("Type 'done' on an empty line when finished.")
69
+ print("Type 'quit' to exit without saving.")
70
+ print()
71
+
72
+ collected = 0
73
+ post_num = 1
74
+
75
+ while True:
76
+ text = get_multiline_input(f"--- Post #{post_num} (paste text, Enter x2 to confirm) ---")
77
+
78
+ if text is None: # quit
79
+ print("\nExiting without saving.")
80
+ return
81
+
82
+ if text == "DONE" or text == "":
83
+ break
84
+
85
+ word_count = len(text.split())
86
+ if word_count < MIN_WORD_COUNT:
87
+ print(f" [skip] Too short ({word_count} words, need {MIN_WORD_COUNT}+). Try again.\n")
88
+ continue
89
+
90
+ # Auto-save immediately β€” no data lost if you Ctrl+C
91
+ save([{"article": text, "label": 1}], OUTPUT_PATH)
92
+ collected += 1
93
+ print(f" βœ“ Auto-saved ({word_count} words). Total in file: {collected}\n")
94
+ post_num += 1
95
+
96
+ if collected == 0:
97
+ print("No posts collected.")
98
+ return
99
+
100
+ print(f"\n{'=' * 58}")
101
+ print(f" Session complete β€” {collected} new post(s) saved.")
102
+ print(f" File: {OUTPUT_PATH}")
103
+ print(f" Now retrain with: python backend/train.py")
104
+ print(f"{'=' * 58}")
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
survey_form.txt ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ================================================================================
2
+ DOCUMENT NO.: FC-SURVEY-001 VERSION: 1.0 DATE: 2026-03-25
3
+ ================================================================================
4
+
5
+ USER SATISFACTION AND SYSTEM EVALUATION SURVEY
6
+ FactCheck: A Multilingual Filipino Fake News Detection System
7
+
8
+ Controlled Document β€” ISO 9001:2015
9
+ Quality Objective: Measure user satisfaction and system effectiveness
10
+ to support continual improvement (Clause 9.1.2)
11
+
12
+ ================================================================================
13
+ INSTRUCTIONS TO RESPONDENTS
14
+ ================================================================================
15
+
16
+ This survey is part of a quality evaluation study for the FactCheck system, a
17
+ multilingual (Filipino/Tagalog, Cebuano, English) AI-based fake news detection
18
+ tool. Your honest responses will directly inform the improvement of the system.
19
+
20
+ Target Respondents : Students enrolled in a Bachelor of Arts in Journalism
21
+ or related Communication programs.
22
+ Estimated Time : 10–15 minutes
23
+ Confidentiality : Responses are anonymous and will be used solely for
24
+ academic research and system improvement purposes.
25
+ Version Control : This form is subject to review and revision under the
26
+ project's Document Control procedure (ISO 9001 Clause 7.5).
27
+
28
+ RATING SCALE:
29
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
30
+ β”‚ Strongly Agree β”‚ Agree β”‚ Neutral β”‚ Disagree β”‚ Strongly Disagree β”‚
31
+ β”‚ (5) β”‚ (4) β”‚ (3) β”‚ (2) β”‚ (1) β”‚
32
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
33
+
34
+ Please write the number (1–5) in the space provided, or tick the appropriate
35
+ box if a printed form is used. Leave no item blank; write N/A only if the
36
+ feature was not available during your session.
37
+
38
+ ================================================================================
39
+ PART I β€” RESPONDENT PROFILE
40
+ ================================================================================
41
+
42
+ Name (optional) : ___________________________________________________________
43
+ Year Level : β–‘ 1st Year β–‘ 2nd Year β–‘ 3rd Year β–‘ 4th Year
44
+ Major / Track : ___________________________________________________________
45
+ School : ___________________________________________________________
46
+ Date Accomplished: ___________________________________________________________
47
+
48
+ Have you previously used any fact-checking tool or platform?
49
+ β–‘ Yes (please specify): _______________________ β–‘ No
50
+
51
+ ================================================================================
52
+ PART II β€” SYSTEM QUALITY
53
+ (ISO 9001 Clause 8.5 β€” Refers to the technical reliability and correctness
54
+ of outputs produced by the system.)
55
+ ================================================================================
56
+
57
+ 5 4 3 2 1
58
+ SA A N D SD
59
+ --- -- -- -- --
60
+ 1. The system correctly identifies fake news articles
61
+ most of the time. [ ] [ ] [ ] [ ] [ ]
62
+
63
+ 2. The system correctly identifies real/legitimate
64
+ news articles most of the time. [ ] [ ] [ ] [ ] [ ]
65
+
66
+ 3. The system's verdict (Verified / Likely Fake /
67
+ Unverified) is accurate based on my journalism
68
+ knowledge. [ ] [ ] [ ] [ ] [ ]
69
+
70
+ 4. The ML confidence score displayed reflects how
71
+ credible the article actually is. [ ] [ ] [ ] [ ] [ ]
72
+
73
+ 5. The pattern deviation feature (comparing the article
74
+ against reputable sources) provides useful and
75
+ accurate results. [ ] [ ] [ ] [ ] [ ]
76
+
77
+ 6. The bias leaning classification (Left / Conservative
78
+ / Centrist / Admin-Right) is appropriate for the
79
+ Philippine media context. [ ] [ ] [ ] [ ] [ ]
80
+
81
+ 7. The system performs consistently across different
82
+ types of news articles (political, crime, health,
83
+ etc.). [ ] [ ] [ ] [ ] [ ]
84
+
85
+ ================================================================================
86
+ PART III β€” INFORMATION QUALITY
87
+ (ISO 9001 Clause 7.5 / 8.6 β€” Refers to the relevance, completeness, and
88
+ clarity of information presented to the user.)
89
+ ================================================================================
90
+
91
+ 5 4 3 2 1
92
+ SA A N D SD
93
+ --- -- -- -- --
94
+ 8. The final verdict and details section clearly
95
+ explains why an article was flagged. [ ] [ ] [ ] [ ] [ ]
96
+
97
+ 9. The LIME explanation (word-level reasoning) helps
98
+ me understand which words contributed to the
99
+ fake/real decision. [ ] [ ] [ ] [ ] [ ]
100
+
101
+ 10. The external source results (Google News / database
102
+ matches) are relevant to the article being checked. [ ] [ ] [ ] [ ] [ ]
103
+
104
+ 11. The misinformation type label (e.g., Fabricated,
105
+ Added Claims/Spin, Old News Recirculated) is
106
+ descriptive and informative. [ ] [ ] [ ] [ ] [ ]
107
+
108
+ 12. The pattern deviation output (extra claims vs.
109
+ corroborated claims) is easy to understand. [ ] [ ] [ ] [ ] [ ]
110
+
111
+ 13. The unsourced claim / "blind item" detection is
112
+ relevant to how fake news spreads on Philippine
113
+ social media. [ ] [ ] [ ] [ ] [ ]
114
+
115
+ 14. The system's output is complete enough for me to
116
+ make a journalism decision about an article. [ ] [ ] [ ] [ ] [ ]
117
+
118
+ ================================================================================
119
+ PART IV β€” USABILITY AND USER EXPERIENCE
120
+ (ISO 9001 Clause 9.1.2 β€” Customer satisfaction; refers to ease of use
121
+ and accessibility of the system interface.)
122
+ ================================================================================
123
+
124
+ 5 4 3 2 1
125
+ SA A N D SD
126
+ --- -- -- -- --
127
+ 15. The system is easy to navigate and use without
128
+ prior technical training. [ ] [ ] [ ] [ ] [ ]
129
+
130
+ 16. The system responds within an acceptable amount
131
+ of time (speed is satisfactory). [ ] [ ] [ ] [ ] [ ]
132
+
133
+ 17. The output is presented in a clear, readable, and
134
+ well-organized format. [ ] [ ] [ ] [ ] [ ]
135
+
136
+ 18. I did not encounter any technical errors or crashes
137
+ during my use of the system. [ ] [ ] [ ] [ ] [ ]
138
+
139
+ 19. The terminology used in the outputs is
140
+ understandable to a journalism student. [ ] [ ] [ ] [ ] [ ]
141
+
142
+ ================================================================================
143
+ PART V β€” RELEVANCE TO JOURNALISM PRACTICE
144
+ (ISO 9001 Clause 6.1 / 8.2 β€” Addresses whether the system meets the
145
+ specific needs of its intended users β€” journalism students and practitioners.)
146
+ ================================================================================
147
+
148
+ 5 4 3 2 1
149
+ SA A N D SD
150
+ --- -- -- -- --
151
+ 20. The system supports my ability to verify news
152
+ before publishing or sharing it. [ ] [ ] [ ] [ ] [ ]
153
+
154
+ 21. The multilingual support (Filipino/Tagalog, Cebuano,
155
+ English) is important for Philippine journalism. [ ] [ ] [ ] [ ] [ ]
156
+
157
+ 22. I would recommend this tool to fellow journalism
158
+ students or practicing journalists. [ ] [ ] [ ] [ ] [ ]
159
+
160
+ 23. The bias analysis module would help newsrooms detect
161
+ editorial bias in their own content. [ ] [ ] [ ] [ ] [ ]
162
+
163
+ 24. The system would be a useful tool for fact-checking
164
+ social media posts in the Philippine context. [ ] [ ] [ ] [ ] [ ]
165
+
166
+ 25. The satire detection capability is relevant to the
167
+ current fake news problem on Philippine social media.[ ] [ ] [ ] [ ] [ ]
168
+
169
+ ================================================================================
170
+ PART VI β€” OVERALL SATISFACTION
171
+ (ISO 9001 Clause 9.1.2 β€” Overall customer satisfaction measurement.)
172
+ ================================================================================
173
+
174
+ 5 4 3 2 1
175
+ SA A N D SD
176
+ --- -- -- -- --
177
+ 26. Overall, I am satisfied with the performance of
178
+ the FactCheck system. [ ] [ ] [ ] [ ] [ ]
179
+
180
+ 27. Overall, the system meets my expectations as a
181
+ journalism student. [ ] [ ] [ ] [ ] [ ]
182
+
183
+ 28. I believe this system has potential for real-world
184
+ use in Philippine media organizations. [ ] [ ] [ ] [ ] [ ]
185
+
186
+ ================================================================================
187
+ PART VII β€” OPEN-ENDED QUESTIONS
188
+ (Qualitative data for continual improvement β€” ISO 9001 Clause 10.3)
189
+ ================================================================================
190
+
191
+ 29. What feature of the system did you find most useful? Why?
192
+
193
+ ________________________________________________________________________
194
+
195
+ ________________________________________________________________________
196
+
197
+ ________________________________________________________________________
198
+
199
+ 30. What aspect of the system needs the most improvement?
200
+
201
+ ________________________________________________________________________
202
+
203
+ ________________________________________________________________________
204
+
205
+ ________________________________________________________________________
206
+
207
+ 31. Were there any outputs or results that you found confusing or misleading?
208
+ Please provide a specific example if possible.
209
+
210
+ ________________________________________________________________________
211
+
212
+ ________________________________________________________________________
213
+
214
+ ________________________________________________________________________
215
+
216
+ 32. As a journalism student, what additional features would make this system
217
+ more useful in your daily work?
218
+
219
+ ________________________________________________________________________
220
+
221
+ ________________________________________________________________________
222
+
223
+ ________________________________________________________________________
224
+
225
+ 33. Any other comments or suggestions:
226
+
227
+ ________________________________________________________________________
228
+
229
+ ________________________________________________________________________
230
+
231
+ ________________________________________________________________________
232
+
233
+ ================================================================================
234
+ SCORING AND INTERPRETATION GUIDE (For Researcher Use Only)
235
+ ================================================================================
236
+
237
+ Score Range Descriptive Interpretation
238
+ ────────────── ──────────────────────────────────────────────────────
239
+ 4.50 – 5.00 Excellent β€” Exceeds user expectations
240
+ 3.50 – 4.49 Good β€” Meets user expectations with minor gaps
241
+ 2.50 – 3.49 Fair β€” Partially meets expectations; improvement needed
242
+ 1.50 – 2.49 Poor β€” Does not meet expectations; major revision needed
243
+ 1.00 – 1.49 Very Poor β€” Fails to meet minimum acceptable standards
244
+
245
+ Weighted Mean Formula:
246
+ WM = Ξ£(f Γ— w) / N
247
+ where f = frequency of responses, w = weight (1–5), N = total respondents
248
+
249
+ Reliability Target (ISO 9001 Clause 9.1.3):
250
+ Cronbach's Alpha β‰₯ 0.70 is required for instrument reliability.
251
+ Compute using statistical software (SPSS, R, or Python/pingouin) prior
252
+ to reporting results.
253
+
254
+ ================================================================================
255
+ DOCUMENT CONTROL INFORMATION (ISO 9001 Clause 7.5.2 / 7.5.3)
256
+ ================================================================================
257
+
258
+ Document Title : User Satisfaction and System Evaluation Survey
259
+ Document No. : FC-SURVEY-001
260
+ Version : 1.0
261
+ Prepared by : [Researcher Name / Institution]
262
+ Reviewed by : [Thesis Adviser / Quality Representative]
263
+ Approved by : [Department Chair / IRB / Ethics Committee]
264
+ Date Issued : 2026-03-25
265
+ Review Schedule : Prior to each data collection round, or upon major
266
+ system update that affects surveyed features.
267
+ Status : β–‘ Draft β–‘ For Review β–  Released β–‘ Superseded
268
+
269
+ REVISION HISTORY
270
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
271
+ β”‚ Version β”‚ Date β”‚ Description β”‚ Revised By β”‚
272
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
273
+ β”‚ 1.0 β”‚ 2026-03-25 β”‚ Initial release β”‚ [Author] β”‚
274
+ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
275
+ β”‚ β”‚ β”‚ β”‚ β”‚
276
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
277
+
278
+ ================================================================================
279
+ END OF DOCUMENT
280
+ FC-SURVEY-001 v1.0 | FactCheck Multilingual Fake News Detector
281
+ ================================================================================