Spaces:
Runtime error
Runtime error
fborj commited on
Commit Β·
047e937
1
Parent(s): be0a973
Integrated pattern deviation, fixed Cebuano modeling, and added ISO Survey
Browse files- backend/train.py +102 -25
- check_app/pubspec.lock +4 -4
- check_article.py +85 -0
- checker/external/pattern_deviation.py +216 -0
- checker/fact_checker.py +93 -33
- checker/internal/bias_analyzer.py +40 -13
- checker/internal/core.py +11 -3
- scraper/collect_satire_manual.py +108 -0
- survey_form.txt +281 -0
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 |
-
#
|
| 1017 |
-
#
|
| 1018 |
-
#
|
| 1019 |
-
|
| 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 |
-
# [
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1084 |
|
| 1085 |
-
# [aug] MT-
|
| 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.
|
| 1184 |
-
|
| 1185 |
2. undersample=True β downsample the majority class (legacy fallback).
|
| 1186 |
Both: oversample is applied first, then undersample (rarely needed).
|
| 1187 |
-
Neither:
|
|
|
|
| 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=
|
| 1366 |
min_samples_split=5,
|
| 1367 |
-
min_samples_leaf=
|
| 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=
|
| 1383 |
min_samples_split=5,
|
| 1384 |
-
min_samples_leaf=
|
| 1385 |
class_weight="balanced",
|
| 1386 |
n_jobs=-1,
|
| 1387 |
random_state=42,
|
|
@@ -1542,12 +1607,24 @@ def main():
|
|
| 1542 |
)
|
| 1543 |
|
| 1544 |
# 2. Preprocess
|
| 1545 |
-
#
|
| 1546 |
-
#
|
| 1547 |
-
|
|
|
|
|
|
|
| 1548 |
|
| 1549 |
# 3. Train & evaluate
|
| 1550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 152 |
url: "https://pub.dev"
|
| 153 |
source: hosted
|
| 154 |
-
version: "0.12.
|
| 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: "
|
| 325 |
url: "https://pub.dev"
|
| 326 |
source: hosted
|
| 327 |
-
version: "0.7.
|
| 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.
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
stylo = extract_stylometric_features(clean_text(analysis_text))
|
| 88 |
present_focus = stylo[23] if len(stylo) > 23 else 0.0
|
| 89 |
|
| 90 |
-
#
|
| 91 |
article_date, date_flags = self._validate_dates(external_result, present_focus)
|
| 92 |
|
| 93 |
-
#
|
| 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,
|
|
|
|
| 97 |
)
|
| 98 |
|
| 99 |
-
#
|
| 100 |
obfuscated = internal_result.get("bias", {}).get("obfuscated_text", False)
|
| 101 |
misinfo_type = self._classify_misinfo_type(
|
| 102 |
-
internal_result, external_result, date_flags,
|
|
|
|
| 103 |
)
|
| 104 |
|
| 105 |
-
#
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
if is_old_news:
|
| 361 |
return "π© Old News Recirculated β Real event, but being shared as if it's current news"
|
| 362 |
|
| 363 |
-
# Priority
|
| 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
|
| 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
|
| 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
|
| 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
|
| 380 |
if ext_verdict == "VERIFIED":
|
| 381 |
return "β
No misinformation detected β Article verified by matching news sources"
|
| 382 |
|
| 383 |
-
# Priority
|
| 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 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
if source_count > 1:
|
| 445 |
details += f" {source_count} corroborating sources found."
|
| 446 |
elif ext_verdict == "COVERAGE_FOUND_ONLINE":
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 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:
|
| 1254 |
-
is_biased:
|
| 1255 |
-
unsourced_claim:
|
| 1256 |
-
unsourced_score:
|
| 1257 |
-
subjectivity:
|
| 1258 |
-
subjectivity_flag:
|
| 1259 |
-
vader_compound:
|
| 1260 |
-
vader_biased:
|
| 1261 |
-
confidence:
|
| 1262 |
-
|
|
|
|
|
|
|
| 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 |
+
================================================================================
|