fborj commited on
Commit
73b8fe9
Β·
verified Β·
1 Parent(s): 3ea6dab

updated changes in pro-admin bias verdict

Browse files
Files changed (1) hide show
  1. checker/internal/bias_analyzer.py +1406 -1423
checker/internal/bias_analyzer.py CHANGED
@@ -1,1423 +1,1406 @@
1
- """
2
- Bias Analyzer β€” Heuristic-based political bias detection + subjectivity scoring
3
- =================================================================================
4
- Analyzes Filipino news articles for political bias using:
5
- - Keyword patterns (left/right/sensational)
6
- - Philippine political figure sentiment (2025-2026 context)
7
- - Unsourced claim / "blind item" detection
8
- - Opinion markers and subjectivity thresholds
9
- - VADER sentiment scoring
10
-
11
- Returns: Left-leaning, Right-leaning, Conservative, or Centrist.
12
- Also flags unsourced claims (chismis / blind item style fake news).
13
- """
14
-
15
- import re
16
- from textblob import TextBlob
17
- from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
18
-
19
- # Shared VADER analyzer instance
20
- _vader = SentimentIntensityAnalyzer()
21
-
22
-
23
- # ── Philippine Political Figure Keywords (2025-2026 context) ────────────────
24
- #
25
- # The Philippine political landscape after 2024 has three main camps:
26
- # CONSERVATIVE (Duterte faction) β€” Pro-Duterte, Anti-Marcos, Anti-Leni
27
- # ADMIN/RIGHT (Marcos faction) β€” Pro-Marcos admin, critical of Duterte now
28
- # LIBERAL/LEFT (Leni bloc) β€” Pro-Leni, Anti-Duterte, Anti-Marcos
29
- #
30
- # A CENTRIST does not side with personalities but examines credentials and policy.
31
-
32
- # Phrases used by/about the DUTERTE FACTION (conservative/nationalist)
33
- CONSERVATIVE_PRO = [
34
- "tatay digong",
35
- "digong",
36
- "mayor duterte",
37
- "president duterte",
38
- "rody duterte",
39
- "rodrigo duterte",
40
- "war on drugs works",
41
- "drug war effective",
42
- "bong go",
43
- "sara duterte is right",
44
- "vp sara",
45
- "baste duterte",
46
- "dds",
47
- "duterte die-hard supporter",
48
- "pro-duterte",
49
- "18 marines",
50
- "federalismo",
51
- "charter change ni duterte",
52
- "the dutertes",
53
- "duterte legacy",
54
- "pagmamahal sa bansa",
55
- "du30",
56
- "DU30",
57
- "DDS",
58
- "Tatay Digong",
59
- "πŸ‘Š",
60
- "πŸ‘ŠπŸ‡΅πŸ‡­",
61
- "Dilawan",
62
- "Yellowtard",
63
- "Lugaw",
64
- "Lutang",
65
- "Penoy",
66
- "Oligarchs",
67
- "Drug war",
68
- "Build Build Build",
69
- "Safety first",
70
- "Strongman",
71
- "Disiplina",
72
- "Bayaran",
73
- "Presinto",
74
- "Fake news media",
75
- "Biased",
76
- "NPAs",
77
- "Pula-pula",
78
- "Aktibista",
79
- "Woke",
80
- "Moralista",
81
- "Elitista",
82
- "Matapang",
83
- "Davao",
84
- "Tapang at Malasakit",
85
- "May gawa, hindi salita",
86
- "Solid",
87
- "Diehard",
88
- "Puro reklamo",
89
- "Sumunod na lang",
90
- "Bakit hindi ka tumakbo?",
91
- "Ambisyosa",
92
- "Dating VP",
93
- "Len-Len",
94
- "Noynoying",
95
- "Komunista",
96
- "Katotohanan",
97
- "DDS",
98
- "Digong",
99
- "Tatay Digong",
100
- "PRRD",
101
- "tapang at malasakit",
102
- "Dutertards", # self-reclaimed by some supporters
103
- "tokhang",
104
- "war on drugs",
105
- "change is coming",
106
- "federalismo",
107
- "BBL supporter",
108
- "Build Build Build",
109
- "Mindanaoan pride",
110
- "Davao model",
111
- "anti-dilawan",
112
- "dilawan tayo", # used sarcastically against critics
113
- "presyo ng bigas", # rice price talking point
114
- "disente daw", # mockery of "decent" critics
115
- "RP first",
116
- "sovereign PH",
117
- "OFW ni Digong",
118
- "ICC out",
119
- "ICC biased",
120
- "CHR useless",
121
- "media biased", # ABS-CBN franchise issue framing
122
- "NTF-ELCAC",
123
- "red-tagging justified",
124
- "leftist agenda",
125
- "destabilize",
126
- "dilawang media",
127
- "lugaw", # derogatory term for Robredo
128
- "walang nagawa si Leni",
129
- "Marcos-Duterte alliance",
130
- "UniTeam loyalist",
131
- "Hugpong",
132
- "PDDS",
133
- "Katas ng Saudi", # OFW remittance pride narrative
134
- "drug lord protectors",
135
- "yellows",
136
- "Dutertista",
137
- ]
138
- CONSERVATIVE_ANTI = [
139
- # Anti-Marcos (used by Duterte faction after their fallout)
140
- "marcos incompetent",
141
- "marcos palpak",
142
- "marcos nagkanulo",
143
- "ibaba si marcos",
144
- "marcos ay traydor",
145
- "marcos nagpalit ng tuktok",
146
- "marcos dynasty",
147
- "marcos diktador",
148
- "bangag",
149
- "Magnanakaw",
150
- "BabyM",
151
- "LBM",
152
- "Sinungaling",
153
- "Unity-ng-magnanakaw",
154
- "Party President",
155
- "Bangag",
156
- "Cocaine-user",
157
- "Dictator junior",
158
- "Tax evader",
159
- "Mandaraya",
160
- "Golden Age (ironic)",
161
- "Tallano Gold (mocking)",
162
- "Traveler-in-Chief",
163
- "Unity-is-scam",
164
- "Historical distortor",
165
- "Puppet of Romualdez",
166
- "Weak leader",
167
- "Walang nagawa",
168
- "Incompetent",
169
- "Flood control scandal",
170
- "Trillion Peso March",
171
- "Ill-gotten wealth",
172
- "Martial law baby",
173
- "Vlog-president",
174
- "Absentee leader",
175
- "Impeachment target",
176
- "Marcos-crony",
177
- "BBM-serye",
178
- "Nepo-baby",
179
- "Unite-troll",
180
- "Bagong Pilipinas (mocking)",
181
- "Maharlika-scam",
182
- "Rice at 20 pesos (mocking)",
183
- "Unity-over-justice",
184
- "Puro party",
185
- "Walang kusa",
186
- "Fake diploma",
187
- "Legacy-washer",
188
- "Marcos-pa-rin (ironic)"
189
- # Anti-Leni
190
- "dilawan",
191
- "kakampink",
192
- "kakampwet",
193
- "hontivirus",
194
- "trilliling",
195
- "leni lugaw",
196
- "yellow army",
197
- "kakampink talo",
198
- "leni talo",
199
- "leni sinungaling",
200
- "leni bayad",
201
- "aquino oligarch",
202
- "oposisyon mapanlinlang",
203
- "sentenaryo ng laban nila",
204
- "Lugaw",
205
- "Lutang",
206
- "Pinktard",
207
- "Kakampwet",
208
- "NPA-sympathizer",
209
- "Elitista",
210
- "Matapobre",
211
- "Moral high grounder",
212
- "Dilawan",
213
- "Yellowtard",
214
- "Fake VP",
215
- "Puppet of the US",
216
- "Oligarch-funded",
217
- "Puro dada",
218
- "Self-righteous",
219
- "Woke",
220
- "Cancel culture",
221
- "Leni-lenin",
222
- "Komunista",
223
- "Pabida",
224
- "Pa-victim",
225
- "Len-len",
226
- "Madumb",
227
- "Puppet",
228
- "Lahat na lang mali",
229
- "Neoliberal",
230
- "Yellow-bleeding",
231
- "Manila-centric",
232
- "Hypocrite",
233
- "The 1%",
234
- "Walang alam",
235
- "Staging photos",
236
- "PR-driven",
237
- "Puro puso walang utak",
238
- "Kulto ni Leni",
239
- "Privileged",
240
- "Out of touch",
241
- "Political loser",
242
- "Ambisyosa",
243
- "Bakit hindi ka tumakbo?",
244
- "lugaw", # derogatory nickname for Robredo
245
- "incompetente si Leni",
246
- "walang nagawa si VP",
247
- "plastic na pagmamahal",
248
- "pang-epal lang",
249
- "OVP budget sayang",
250
- "pink propaganda",
251
- "emotional voters",
252
- "bobotante ng emosyon",
253
- "dilawan ulit",
254
- "yellowtard",
255
- "Aquino alaga",
256
- "Cory hangover",
257
- "oligarko ng LP",
258
- "LP trapo",
259
- "Liberal Party corrupt",
260
- "Robredo incompetent",
261
- "lugaw republic",
262
- "pabor sa NPA",
263
- "komunistang suporta",
264
- "abogadong walang alam",
265
- "epal na relief ops",
266
- "drama lang siya",
267
- "intsik ang pondo", # baseless China-funding smear
268
- "kakampink bobo",
269
- "pink cult",
270
- "sira ang isip ng pink",
271
- "Leni talo na talo",
272
- "loser si Leni",
273
- "hindi electable",
274
- "media darling lang",
275
- "biased na survey",
276
- "SWS peke",
277
- "Pulse Asia bayad",
278
- "mainstream media alaga",
279
- "woke politics",
280
- "imported agenda",
281
- "western puppet",
282
- "NGO funded Leni",
283
- "traydor sa Visayas",
284
- ]
285
-
286
- # Phrases used by/about the MARCOS ADMINISTRATION faction
287
- ADMIN_PRO = [
288
- "president marcos",
289
- "bongbong marcos",
290
- "bbm", # NOTE: kept for compound matching but see neutral filter below
291
- "pbbm",
292
- "marcos administration",
293
- "marcos government",
294
- "uniteam",
295
- "marcos legacy",
296
- "bagong pilipinas",
297
- "marcos economic plan",
298
- "first lady liza",
299
- "liza araneta-marcos",
300
- "marcos right",
301
- "marcos tama",
302
- "marcos galing",
303
- "marcos matino",
304
- # NOTE: 'PBBM' and 'BBM' removed as standalone bias signals.
305
- # These are neutral abbreviations used by ALL Philippine media to refer to
306
- # the sitting president. Only biased contextual phrases are kept.
307
- "UniTeam",
308
- "❀️",
309
- "✌️",
310
- "Solid North",
311
- "Bagong Pilipinas",
312
- "Unity",
313
- "Pagkakaisa",
314
- "Marcosian",
315
- "Loyalist",
316
- "Tiger of the North",
317
- "BBM-Sara",
318
- "Economic recovery",
319
- "Maharlika",
320
- "Kadiwa",
321
- "Food security",
322
- "Legacy",
323
- "Golden Age",
324
- "Moving forward",
325
- "Forgiveness",
326
- "Negative campaigning",
327
- "Paninira",
328
- "Pinktard",
329
- "Kakampwet",
330
- "Uto-uto",
331
- "Propaganda",
332
- "Stability",
333
- "Continuity",
334
- "Diplomacy",
335
- "Pres. Bongbong",
336
- "First Family",
337
- "Infrastructure",
338
- "Modernization",
339
- "Walang iwanan",
340
- "Pilipinas Muna",
341
- "Tigre",
342
- "Pula",
343
- "Bangon muli",
344
- "Sama-sama",
345
- "Bagong Umaga",
346
- # Removed standalone 'PBBM' and 'BBM' β€” neutral abbreviations
347
- "Bongbong Marcos",
348
- "Bagong Pilipinas",
349
- "UniTeam",
350
- "Sara Duterte",
351
- "Solid Marcos",
352
- "Marcos pa rin",
353
- "golden age ng Marcos",
354
- "rice at P20",
355
- "mahirap pag-usapan ang kasaysayan", # revisionism framing
356
- "historical revisionism daw",
357
- "Imee Marcos",
358
- "Ilocano pride",
359
- "Solid North",
360
- "MarcosLegacy",
361
- "hindi communista",
362
- "federalismo ni BBM",
363
- "infra development",
364
- "GNP growth",
365
- "Marcos era golden years",
366
- "Tallano gold", # fringe pro-Marcos wealth myth
367
- "Yamashita treasure",
368
- "opposition nagrereklamo lang",
369
- "KKB (Kapit-Kapit Bisig)",
370
- "probinsya first",
371
- "sariling atin", # economic nationalism framing
372
- "Maharlika Fund",
373
- "MIF supporter",
374
- "DICT projects",
375
- "Marcos hindi magnanakaw",
376
- "rehabilitasyon ni Marcos",
377
- "marcos loyalista",
378
- "bisita Marcos museum",
379
- "Solid Cebu",
380
- "Solid Mindanao",
381
- "admin side",
382
- "Malacanang suporta",
383
- "Sara VP proud",
384
- "BBM-Sara tandem",
385
- ]
386
- ADMIN_ANTI = [
387
- # Anti-Duterte (used by Marcos camp after fallout)
388
- "duterte corrupt",
389
- "duterte accountability",
390
- "duterte icc",
391
- "duterte drug lord",
392
- "duterte criminal",
393
- "arrest duterte",
394
- "duterte drug syndicate",
395
- "duterte narco",
396
- ]
397
-
398
- # Phrases used by/about the LIBERAL/LEFT (Leni bloc)
399
- LIBERAL_PRO = [
400
- "leni robredo",
401
- "si leni",
402
- "leni honest",
403
- "leni tapat",
404
- "angat buhay ni leni",
405
- "kakampink",
406
- "kay leni",
407
- "kiko pangilinan",
408
- "leila de lima",
409
- "liberals tama",
410
- "leni integrity",
411
- "leni credentials",
412
- "leni public service",
413
- "pink movement",
414
- "leni sincere",
415
- "walang corrupt si leni",
416
- "laban ng masa",
417
- "makabayan bloc",
418
- "progressive",
419
- "Kakampink",
420
- "Leni-Kiko",
421
- "πŸ’—",
422
- "🌸",
423
- "Angat Buhay",
424
- "Kulay Rosas",
425
- "Husay at Tibay",
426
- "Gobyernong Tapat",
427
- "Radikal ang Magmahal",
428
- "Resibo",
429
- "Fact-check",
430
- "Trolls",
431
- "Magnanakaw",
432
- "Dictator",
433
- "Historical distortion",
434
- "Unity is not enough",
435
- "Walang Solid North",
436
- "Bardagulan",
437
- "Tumindig",
438
- "Para sa Bansa",
439
- "Volunteers",
440
- "Pink Movement",
441
- "Liwanag sa Dilim",
442
- "Laban Leni",
443
- "Let Leni Lead",
444
- "OVP",
445
- "Disinformation",
446
- "Tallano Gold",
447
- "BBM",
448
- "LBM",
449
- "Sinungaling",
450
- "Tax evader",
451
- "Mulat",
452
- "Kababaihan",
453
- "Youth vote",
454
- "Para sa kinabukasan",
455
- "Transparency",
456
- "Accountability",
457
- "Edukado",
458
- "Ipanalo Natin 'To",
459
- "Kakampink",
460
- "Leni Lugaw", # reclaimed term of endearment
461
- "Kay Leni tayo",
462
- "Pink movement",
463
- "Pink volunteer",
464
- "Angat Buhay",
465
- "OVP proud",
466
- "VP Leni",
467
- "Team Leni",
468
- "masa tayo",
469
- "puso ng serbisyo",
470
- "bobotante tayo",
471
- "dilaw", # reclaimed yellow color symbol
472
- "totoong pagbabago",
473
- "Cory legacy",
474
- "EDSA spirit",
475
- "Never Again",
476
- "Martial Law victims",
477
- "human rights first",
478
- "CHR matters",
479
- "fact check muna",
480
- "propaganda detector",
481
- "akademiko tayo",
482
- "Robredo admin",
483
- "Vote wisely",
484
- "hindi pa huli",
485
- "madaming puso",
486
- "serbisyong totoo",
487
- "Comelec transparency",
488
- "kontra sa dayaan",
489
- "boto natin 'to",
490
- "PinkLaban",
491
- "pag-asa ng bayan",
492
- "bukas na isip",
493
- "anti-red-tagging",
494
- "INC split", # electoral coalition discussion
495
- "masa over masa",
496
- "Leody-Leni",
497
- "sama-sama tayo",
498
- "Laban Leni",
499
- ]
500
- LIBERAL_ANTI = [
501
- # Anti-Duterte (mainstay of liberal discourse)
502
- "extrajudicial killing",
503
- "ejk",
504
- "ej killing",
505
- "duterte human rights",
506
- "duterte impunity",
507
- "duterte murders",
508
- "drug war victims",
509
- "tokhang victims",
510
- "Dutertard",
511
- "DDS-troll",
512
- "Fentanyl",
513
- "Davao Group",
514
- "ICC-bound",
515
- "Traydor",
516
- "Kill kill kill",
517
- "EJK-enabler",
518
- "False prophet",
519
- "Budol",
520
- "Wumao",
521
- "China-doll",
522
- "POGO-protector",
523
- "Kulto",
524
- "Brainwashed",
525
- "Violence-prone",
526
- "Davao Dynasty",
527
- "Mamatay-tao",
528
- "Bastos",
529
- "Digong-iyakin",
530
- "Paid trolls",
531
- "Fake news peddler",
532
- "Tuta ng China",
533
- "Digunggong",
534
- "Bisakol (derogatory context)",
535
- "Confidential funds queen",
536
- "Impeachable",
537
- "Threat to democracy",
538
- "War on drugs failure",
539
- "Galing Davao",
540
- "Siga-siga",
541
- "No respect for law",
542
- "Human rights violator",
543
- "Extra-judicial",
544
- "Blind follower",
545
- "DDS-shaming",
546
- "Toxic",
547
- "Divisionist",
548
- "Feudal",
549
- "Oust-Duterte",
550
- "Dutertador", # Duterte + diktador
551
- "diktador",
552
- "pasistang rehimen",
553
- "state-sponsored killing",
554
- "EJK", # extra-judicial killing
555
- "extra-judicial killing",
556
- "dead on arrival", # EJK slang
557
- "drug war patay",
558
- "salvage victim",
559
- "patayin ang mahirap",
560
- "tokhang para sa mayaman",
561
- "tulak pababa", # pushing down the poor
562
- "gulat na gulat", # ironic shock at impunity
563
- "impunidad",
564
- "ICC isampa", # ICC filing demand
565
- "idemanda si Duterte",
566
- "war criminal",
567
- "Davao Death Squad",
568
- "DDS killer",
569
- "Duterte resign",
570
- "Duterte pababa",
571
- "diktadurang Duterte",
572
- "pinaslang",
573
- "Duterte drug lord",
574
- "narco list fake",
575
- "red-tagging biktima",
576
- "NTF-ELCAC terorista",
577
- "baril batas", # "gun is the law" critique
578
- "walang due process",
579
- "peke na pagbabago",
580
- "tuta ng China", # China appeasement critique
581
- "nagbenta ng teritoryo",
582
- "West Philippine Sea talunan",
583
- "basura na pangako",
584
- "sinungaling si Duterte",
585
- "ABS-CBN shutdown",
586
- "franchise denied",
587
- "presyo tumaas",
588
- "gutom sa ilalim ni Duterte",
589
- "human rights patay",
590
- # Anti-Marcos
591
- "never forget martial law",
592
- "hindi makakalimot",
593
- "marcos plunder",
594
- "marcos ill-gotten wealth",
595
- "marcos human rights abuses",
596
- "batas militar",
597
- "martial law victims",
598
- "diktadurang Marcos",
599
- "Never Again Marcos",
600
- "Martial Law atrocities",
601
- "Marcos diktador",
602
- "Ferdinand Marcos magnanakaw",
603
- "ill-gotten wealth",
604
- "nakawin ng Marcos",
605
- "Marcos thief",
606
- "PCGG asset",
607
- "ibalik ang ninakaw",
608
- "human rights abuses",
609
- "desaparecido",
610
- "torture victim Marcos",
611
- "1081 batas militar",
612
- "September 21 hindi kalimutan",
613
- "Marcos burial laban",
614
- "LNMB disgrasya",
615
- "historical revisionist",
616
- "fake news Marcos",
617
- "propaganda ng Marcos",
618
- "BBM bobo",
619
- "BBM pasang-awa",
620
- "grade fake BBM",
621
- "Oxford dropout",
622
- "hindi nagtapos",
623
- "plunder case Marcos",
624
- "tax evasion Marcos",
625
- "hindi nagbayad ng buwis",
626
- "Maharlika Fund scam",
627
- "MIF panganib",
628
- "Marcos crony",
629
- "Enrile kasama",
630
- "Sara Duterte traydor",
631
- "UniTeam nagkahiwalay",
632
- "Marcos-Duterte gulo",
633
- "Solid North bulok",
634
- "admin corruption",
635
- "Malacanang sinungaling",
636
- "BBM resign",
637
- "Marcos pababa",
638
- ]
639
-
640
- # Centrist / evidence-based signals
641
- EVIDENCE_BASED_MARKERS = [
642
- "ayon sa datos",
643
- "according to official records",
644
- "based on data",
645
- "per official report",
646
- "according to the commission",
647
- "sinabi ng opisyal",
648
- "ayon sa pag-aaral",
649
- "studies show",
650
- "data shows",
651
- "statistics show",
652
- "according to the survey",
653
- "sinabi ng eksperto",
654
- "expert said",
655
- "peer-reviewed",
656
- "official statement",
657
- "press release",
658
- "on record",
659
- "verified by",
660
- "fact-checked",
661
- "source:",
662
- "sources:",
663
- "according to",
664
- "as reported by",
665
- "per the department",
666
- "ayon sa palasyo",
667
- "ayon sa doj",
668
- "ayon sa comelec",
669
- "ayon sa ched",
670
- "ayon sa doh",
671
- "ayon sa deped",
672
- "transcript shows",
673
- "court records",
674
- "senate hearing",
675
- "house hearing",
676
- "plunder case",
677
- "graft case",
678
- "ombudsman",
679
- ]
680
-
681
-
682
- # ── Generic Left/Right Keywords (broader patterns) ───────────────────────────
683
-
684
- RIGHT_LEANING_KEYWORDS = [
685
- "nagtagumpay",
686
- "matagumpay",
687
- "malaking tulong",
688
- "pagbabago",
689
- "disiplina",
690
- "drug war",
691
- "malasakit",
692
- "build build build",
693
- "matuwid",
694
- "natatapos",
695
- "pangako",
696
- "nagpapatupad",
697
- "achievements",
698
- "anti-drug",
699
- "anti-crime",
700
- "peace and order",
701
- "development",
702
- "ekonomiya",
703
- "destabilize",
704
- "destabilization",
705
- "oposisyon",
706
- "anti-government",
707
- "bias media",
708
- "oligarch",
709
- "propagandist",
710
- "sovereignty",
711
- "soberanya",
712
- "traitor",
713
- "worst traitor",
714
- "national interest",
715
- "patriotism",
716
- "nationalist",
717
- "faithful servant",
718
- "servant of the people",
719
- "wrongly accused",
720
- "political persecution",
721
- "political prisoner",
722
- "legacy",
723
- "icc persecution",
724
- "he murdered no one",
725
- "presumed innocent",
726
- ]
727
-
728
- LEFT_LEANING_KEYWORDS = [
729
- "human rights",
730
- "extrajudicial",
731
- "ej killing",
732
- "ejk",
733
- "impunity",
734
- # 'corruption'/'korapsyon' removed β€” used by ALL political sides, causes false positives
735
- # 'accountability'/'transparency' removed β€” same reason (universal talking points)
736
- "press freedom",
737
- "authoritarian",
738
- "dictator",
739
- "diktador",
740
- "martial law",
741
- "red-tagging",
742
- "red tagging",
743
- "activists",
744
- "workers rights",
745
- "labor rights",
746
- "progressive",
747
- "inequality",
748
- "social justice",
749
- "democratic space",
750
- "civil liberties",
751
- "dissent",
752
- "political prisoners",
753
- # Specific to liberal/left PH discourse
754
- "disinformation", # kept β€” used more as anti-fake-news framing by left
755
- "state-sponsored killing",
756
- "oust",
757
- "resign",
758
- ]
759
-
760
-
761
- # ── Unsourced Claim / "Blind Item" Signals ───────────────────────────────────
762
- #
763
- # Fake news on Philippine Facebook often mimics showbiz blind items or
764
- # "chismis" posts: dramatic claims made without named sources, often ending
765
- # with prompts to share before the post is deleted.
766
-
767
- UNSOURCED_CLAIM_SIGNALS = [
768
- # Filipino hearsay markers β€” claim made but no named source
769
- "daw",
770
- "raw",
771
- "diumano",
772
- "umano",
773
- "may nagsabi",
774
- "may nagbalita",
775
- "may nag-ulat",
776
- "may nagpadala sa akin",
777
- "may nagbigay ng impormasyon",
778
- "may kilala akong",
779
- "kilala ko ang nagbigay",
780
- "mula sa isang pinagkakatiwalaang tao",
781
- "mula sa reliable na source",
782
- "reliable ang source",
783
- "hindi ko maaaring ibigay ang pangalan",
784
- "hindi maaaring ibunyag ang pangalan",
785
- "hindi ko masasabi kung sino",
786
- "ipinadala sa akin ng isang kaibigan",
787
- # Verification-prompting phrases (signals the poster doubts authenticity)
788
- "totoo kaya ito",
789
- "tama ba ito",
790
- "kung totoo ito",
791
- "pag-isipan ninyo",
792
- "nakaka-isip",
793
- "nagtataka ako",
794
- # Viral / share-baiting language
795
- "share before deleted",
796
- "i-share bago i-delete",
797
- "share na bago tanggalin",
798
- "bago pa tanggalin",
799
- "delete na ito agad",
800
- "wag palampasin",
801
- "must share",
802
- "share this now",
803
- "share niyo",
804
- "share before they take this down",
805
- # Anonymous-source framing
806
- "anonymous source",
807
- "blind item",
808
- "from a reliable source",
809
- "sources say",
810
- "sources told",
811
- "a source revealed",
812
- # English hearsay
813
- "i heard that",
814
- "i was told that",
815
- "apparently",
816
- "it is said that",
817
- "rumor has it",
818
- "parang totoo",
819
- "unconfirmed reports",
820
- "unverified",
821
- "hindi pa kumpirmado",
822
- "hindi pa confirmed",
823
- "balita lang",
824
- "chismis lang ito",
825
- ]
826
-
827
- # Attribution markers β€” when PRESENT they REDUCE the unsourced claim score
828
- ATTRIBUTION_MARKERS = [
829
- "ayon sa",
830
- "sabi ni",
831
- "according to",
832
- "stated by",
833
- "as reported by",
834
- "cited by",
835
- "based on",
836
- "sinabi ni",
837
- "pahayag ni",
838
- "per ",
839
- "per president",
840
- "per department",
841
- "sinabi ng",
842
- "inihayag ni",
843
- "ipinagpaliwanag ni",
844
- "idineklara ni",
845
- "isinagawa ng",
846
- "pinayagan ni",
847
- "nagpahayag si",
848
- "nagbigay ng pahayag si",
849
- "nagsalita si",
850
- "said ",
851
- "announced ",
852
- "confirmed ",
853
- "stated ",
854
- "declared ",
855
- "officials say",
856
- "government said",
857
- "department said",
858
- "report says",
859
- "study says",
860
- "survey says",
861
- ]
862
-
863
-
864
- # ── Sensationalism / Emotional Charge ────────────────────────────────────────
865
-
866
- SENSATIONAL_KEYWORDS = [
867
- # Filipino
868
- "grabe",
869
- "sobrang",
870
- "nakakatakot",
871
- "nakakalungkot",
872
- "kadiri",
873
- "nakakagalit",
874
- "buwag",
875
- "gago",
876
- "bobo",
877
- "sinungaling",
878
- "traydor",
879
- "pahamak",
880
- "palpak",
881
- "basura",
882
- "walang silbi",
883
- "panalo",
884
- "astig",
885
- "idol",
886
- "legend",
887
- "must read",
888
- "must watch",
889
- "share",
890
- "viral",
891
- "breaking",
892
- "just in",
893
- "urgent",
894
- "adik",
895
- "NAPAKA",
896
- # English β€” emotional/sensational
897
- "shocking",
898
- "outrageous",
899
- "disgusting",
900
- "horrifying",
901
- "terrifying",
902
- "devastating",
903
- "despicable",
904
- "abominable",
905
- "deplorable",
906
- "wickedness",
907
- "depraved",
908
- "stench",
909
- "satan",
910
- "evil",
911
- "fire and fury",
912
- "pipe dream",
913
- "diatribe",
914
- "harangue",
915
- "perfidious",
916
- "despairingly",
917
- "intolerable",
918
- "condemning",
919
- "conspired",
920
- "cronies",
921
- "sham",
922
- "scam",
923
- "hoax",
924
- "unbelievable",
925
- "wake up",
926
- "exposed",
927
- "betrayed",
928
- "rigged",
929
- # Ad hominem
930
- "vile",
931
- "cunning",
932
- "snake",
933
- "poisoned",
934
- "worst traitor",
935
- "go down in history",
936
- "day of reckoning",
937
- "alarming",
938
- "dangerous",
939
- "coward",
940
- "liar",
941
- "hypocrite",
942
- "shameless",
943
- "corrupt",
944
- "criminal",
945
- "thief",
946
- "incompetent",
947
- "puppet",
948
- "lapdog",
949
- "traitor",
950
- "backstabber",
951
- "sellout",
952
- "bankrupt",
953
- "monster",
954
- "villain",
955
- "enemy of the people",
956
- "enemy of the state",
957
- "kakampi ng kaaway",
958
- "tuta",
959
- "duwag",
960
- "magnanakaw",
961
- "masamang tao",
962
- ]
963
-
964
- # First-person / direct opinion markers
965
- OPINION_MARKERS = [
966
- "in my opinion",
967
- "in my assessment",
968
- "in my view",
969
- "i believe",
970
- "i think",
971
- "my legal opinion",
972
- "my honest thoughts",
973
- "i personally",
974
- "from my perspective",
975
- "i am inclined to believe",
976
- "i am inclined",
977
- "i pray",
978
- "i am almost certain",
979
- "i am certain",
980
- "i am convinced",
981
- "i suspect",
982
- "i feel",
983
- "i hope",
984
- "we must",
985
- "we should",
986
- "it is my",
987
- "this writer believes",
988
- "this columnist",
989
- "in this writer",
990
- "i have no doubt",
991
- "without a doubt",
992
- "clearly",
993
- "obviously",
994
- "needless to say",
995
- # Filipino first-person
996
- "sa aking palagay",
997
- "sa tingin ko",
998
- "naniniwala ako",
999
- "sa aking paniniwala",
1000
- ]
1001
-
1002
- SUBJECTIVITY_THRESHOLD = 0.50
1003
-
1004
-
1005
- # ── Helper functions ──────────────────────────────────────────────────────────
1006
-
1007
-
1008
- def _count_keyword_hits(text_lower, keyword_list):
1009
- """Count how many keywords from the list appear in the text."""
1010
- hits = []
1011
- for kw in keyword_list:
1012
- if kw.lower() in text_lower:
1013
- hits.append(kw)
1014
- return hits
1015
-
1016
-
1017
- def _analyze_sensationalism(text_lower):
1018
- """Detect sensationalist language patterns."""
1019
- hits = _count_keyword_hits(text_lower, SENSATIONAL_KEYWORDS)
1020
- exclamation_count = text_lower.count("!")
1021
- question_chains = len(re.findall(r"\?{2,}", text_lower))
1022
- caps_words = len(re.findall(r"\b[A-Z]{3,}\b", text_lower))
1023
-
1024
- score = len(hits) * 2 + min(exclamation_count, 5) + question_chains * 2 + caps_words
1025
- return score, hits
1026
-
1027
-
1028
- def _detect_unsourced_claim(text_lower):
1029
- """Detect 'blind item' / chismis-style unsourced claims.
1030
-
1031
- Returns (score, signals_found, attribution_count).
1032
- High score + low attribution = likely unsourced fake news style.
1033
- """
1034
- signals = _count_keyword_hits(text_lower, UNSOURCED_CLAIM_SIGNALS)
1035
- attributions = _count_keyword_hits(text_lower, ATTRIBUTION_MARKERS)
1036
-
1037
- # Raw score: each unsourced signal +2, each attribution -1 (floor 0)
1038
- raw = len(signals) * 2
1039
- credit = len(attributions)
1040
- score = max(0, raw - credit)
1041
- return score, signals, len(attributions)
1042
-
1043
-
1044
- def _detect_obfuscation(text):
1045
- """Detect intentional misspelling / character substitution used to evade
1046
- content moderation filters.
1047
-
1048
- Fake news peddlers on Philippine social media commonly:
1049
- 1. Replace letters with look-alike symbols: \u0394\u2192a, @\u2192a inside words
1050
- 2. Use leet-speak digits: 3\u2192e, 0\u2192o, 1\u2192i inside words
1051
- 3. Phonetically misspell: 'manga' for 'mga', 'panu' for 'paano'
1052
- 4. Excessive ALL-CAPS beyond known government acronyms
1053
-
1054
- Returns (score, signals_found). Score >= 2 is a reliable flag.
1055
- """
1056
- signals = []
1057
-
1058
- # 1. Explicit character substitutions β€” each hit is worth 2 points
1059
- # (a single substitution is definitive; no need to wait for 2 signals)
1060
- explicit_score = 0
1061
-
1062
- # Delta family: U+2206 INCREMENT βˆ†, U+0394 GREEK DELTA Ξ”,
1063
- # U+25B3 WHITE TRIANGLE β–³, U+25B2 BLACK TRIANGLE β–²
1064
- DELTA_CHARS = "\u2206\u0394\u25b3\u25b2"
1065
- delta_hits = sum(text.count(c) for c in DELTA_CHARS)
1066
- if delta_hits:
1067
- signals.append(f"\u2206/\u0394 used as letter \u00d7 {delta_hits}")
1068
- explicit_score += 2
1069
-
1070
- # Degree symbol \u00b0 used as 'o' (e.g. 'tunt\u00b0' = 'tunto')
1071
- degree_hits = text.count("\u00b0")
1072
- if degree_hits:
1073
- signals.append(f"\u00b0 used as letter \u00d7 {degree_hits}")
1074
- explicit_score += 2
1075
-
1076
- # @ inside a word as 'a' substitution (not an email address)
1077
- at_in_word = len(re.findall(r"\w@\w", text))
1078
- if at_in_word:
1079
- signals.append(f"@ inside word \u00d7 {at_in_word}")
1080
- explicit_score += 2
1081
-
1082
- # Other single-char Latin letter substitutions: Β‘\u2192i, \u00f8\u2192o, \u00a5\u2192y, \u20ac\u2192e
1083
- for ch, label in [
1084
- ("\u00a1", "\u00a1\u2192i"),
1085
- ("\u00f8", "\u00f8\u2192o"),
1086
- ("\u00a5", "\u00a5\u2192y"),
1087
- ("\u20ac", "\u20ac\u2192e"),
1088
- ("\u00a2", "\u00a2\u2192c"),
1089
- ("\u00a3", "\u00a3\u2192b"),
1090
- ]:
1091
- if ch in text:
1092
- signals.append(f"'{label}' substitution")
1093
- explicit_score += 2
1094
-
1095
- # Generic non-ASCII scan (weak signal β€” only add if no explicit sub found)
1096
- non_ascii = len(re.findall(r"[^\x00-\x7F]", text))
1097
- if non_ascii > 0 and explicit_score == 0 and 1 < non_ascii <= 20:
1098
- signals.append(f"non-ASCII chars in Latin text \u00d7 {non_ascii}")
1099
-
1100
- # 2. Leet-speak: digits inside words (e.g. 'm4rcos', '3JK')
1101
- leet_words = [
1102
- w for w in re.findall(r"\b\w*[30145]\w*\b", text) if re.search(r"[a-zA-Z]", w)
1103
- ]
1104
- if len(leet_words) >= 2:
1105
- signals.append(f"leet digits in words: {leet_words[:4]}")
1106
-
1107
- # 3. Known Filipino phonetic misspells / slang markers
1108
- PHONETIC = [
1109
- (r"\bmanga\b", "'manga' for 'mga'"),
1110
- (r"\bkau\b", "'kau' for 'kayo'"),
1111
- (r"\bnman\b", "'nman' for 'naman'"),
1112
- (r"\bpanu\b", "'panu' for 'paano'"),
1113
- (r"\bganun\b", "'ganun' for 'ganoon'"),
1114
- (r"\bbeh\b", "'beh' informal"),
1115
- (r"\bteh\b", "'teh' informal"),
1116
- (r"\bchar\b", "'char' joke marker"),
1117
- (r"\bOMG\b", "'OMG' exclamation"),
1118
- (r"\bWTF\b", "profane exclamation"),
1119
- ]
1120
- tl = text.lower()
1121
- for pat, label in PHONETIC:
1122
- if re.search(pat, tl):
1123
- signals.append(label)
1124
-
1125
- # 4. Excessive ALL-CAPS beyond standard government acronyms
1126
- LEGIT_CAPS = {
1127
- "PNP",
1128
- "AFP",
1129
- "DOH",
1130
- "COMELEC",
1131
- "CHR",
1132
- "DOJ",
1133
- "OMB",
1134
- "DILG",
1135
- "DSWD",
1136
- "CHED",
1137
- "DEPED",
1138
- "DOE",
1139
- "DBM",
1140
- "BIR",
1141
- "PSA",
1142
- "BSP",
1143
- "SEC",
1144
- "LTO",
1145
- "LTFRB",
1146
- "MMDA",
1147
- "NBI",
1148
- "NASA",
1149
- "FBI",
1150
- "CIA",
1151
- "USA",
1152
- "PH",
1153
- "OVP",
1154
- "ICC",
1155
- "DNA",
1156
- "HIV",
1157
- "WHO",
1158
- "WFP",
1159
- "ASEAN",
1160
- "UN",
1161
- "DDS",
1162
- "BBM",
1163
- "PBBM",
1164
- "VP",
1165
- "SWS",
1166
- "PCSO",
1167
- "PAGCOR",
1168
- "EJK",
1169
- "PRRD",
1170
- "AFP",
1171
- }
1172
- suspicious_caps = [
1173
- w for w in re.findall(r"\b[A-Z]{3,}\b", text) if w not in LEGIT_CAPS
1174
- ]
1175
- if len(suspicious_caps) >= 3:
1176
- signals.append(f"excessive ALL-CAPS: {suspicious_caps[:5]}")
1177
- # Heavy ALL-CAPS (β‰₯ 8 non-acronym caps words) alone is a strong social-media
1178
- # obfuscation signal β€” real journalism never shouts entire sentences in caps.
1179
- # Boost explicit_score so the flag triggers even without βˆ†/leet/@ substitutions.
1180
- if len(suspicious_caps) >= 8:
1181
- explicit_score += 2
1182
-
1183
- score = explicit_score + len(signals) - (1 if explicit_score > 0 else 0)
1184
- # ^ explicit hits already added to score; subtract 1 to avoid counting their
1185
- # signal string twice (each explicit hit already contributes to len(signals))
1186
- # Simplify: final score = explicit_score + non-explicit signal count
1187
- non_explicit_signals = [
1188
- s
1189
- for s in signals
1190
- if not any(
1191
- k in s
1192
- for k in ["\u2206", "\u0394", "\u00b0", "@", "substitution", "non-ASCII"]
1193
- )
1194
- ]
1195
- score = explicit_score + len(non_explicit_signals)
1196
- return score, signals
1197
-
1198
-
1199
- def _compute_subjectivity(text):
1200
- """Compute subjectivity score using TextBlob (0=objective, 1=subjective)."""
1201
- try:
1202
- return TextBlob(text).sentiment.subjectivity
1203
- except Exception:
1204
- return 0.0
1205
-
1206
-
1207
- def _compute_vader_sentiment(text):
1208
- """Compute VADER sentiment intensity."""
1209
- try:
1210
- return _vader.polarity_scores(text)
1211
- except Exception:
1212
- return {"compound": 0.0, "pos": 0.0, "neu": 1.0, "neg": 0.0}
1213
-
1214
-
1215
- def _score_political_figures(text_lower):
1216
- """Score political figure sentiment for Philippine context.
1217
-
1218
- Returns (conservative_score, admin_score, liberal_score, figure_hits).
1219
- """
1220
- cons_pro = _count_keyword_hits(text_lower, CONSERVATIVE_PRO)
1221
- cons_anti = _count_keyword_hits(text_lower, CONSERVATIVE_ANTI)
1222
- adm_pro = _count_keyword_hits(text_lower, ADMIN_PRO)
1223
- adm_anti = _count_keyword_hits(text_lower, ADMIN_ANTI)
1224
- lib_pro = _count_keyword_hits(text_lower, LIBERAL_PRO)
1225
- lib_anti = _count_keyword_hits(text_lower, LIBERAL_ANTI)
1226
- evidence = _count_keyword_hits(text_lower, EVIDENCE_BASED_MARKERS)
1227
-
1228
- # Net scores: positive praise - negative criticism signals the faction leaning
1229
- conservative_score = len(cons_pro) * 2 + len(cons_anti) * 1
1230
- admin_score = len(adm_pro) * 2 + len(adm_anti) * 1
1231
- liberal_score = len(lib_pro) * 2 + len(lib_anti) * 1
1232
- evidence_score = len(evidence)
1233
-
1234
- figure_hits = {
1235
- "conservative_pro": cons_pro,
1236
- "conservative_anti": cons_anti,
1237
- "admin_pro": adm_pro,
1238
- "admin_anti": adm_anti,
1239
- "liberal_pro": lib_pro,
1240
- "liberal_anti": lib_anti,
1241
- "evidence_markers": evidence,
1242
- }
1243
- return conservative_score, admin_score, liberal_score, evidence_score, figure_hits
1244
-
1245
-
1246
- # ── Main function ─────────────────────────────────────────────────────────────
1247
-
1248
-
1249
- def analyze_bias(text, pattern_deviation=None, lang="en"):
1250
-
1251
- """Analyze a news article for political bias and subjectivity.
1252
-
1253
- Args:
1254
- text (str): Article text.
1255
- pattern_deviation (dict|None): Result from compare_to_external_pattern().
1256
- When provided, deviation from reputable sources directly boosts
1257
- sensationalism score and leaning confidence β€” making pattern deviation
1258
- a primary signal rather than a post-hoc annotation.
1259
-
1260
- Returns:
1261
- dict with keys:
1262
- leaning: "Conservative", "Admin/Right", "Left-leaning", or "Centrist"
1263
- is_biased: bool β€” True if clearly leaning or sensational
1264
- unsourced_claim: bool β€” True if article appears to be a blind item / chismis
1265
- unsourced_score: int β€” raw unsourced claim score
1266
- subjectivity: float (0-1)
1267
- subjectivity_flag: bool
1268
- vader_compound: float
1269
- vader_biased: bool
1270
- confidence: float (0-1)
1271
- pattern_deviation_verdict: str β€” "FOLLOWS PATTERN", "MINOR DEVIATION", "SIGNIFICANT DEVIATION", or ""
1272
- deviation_score: float β€” 0.0–1.0 fraction of sentences not in reputable sources
1273
- details: dict with full breakdown
1274
- """
1275
- if not text or not isinstance(text, str) or len(text.strip()) < 20:
1276
- return {
1277
- "leaning": "Centrist",
1278
- "is_biased": False,
1279
- "unsourced_claim": False,
1280
- "unsourced_score": 0,
1281
- "obfuscated_text": False,
1282
- "obfuscation_score": 0,
1283
- "subjectivity": 0.0,
1284
- "subjectivity_flag": False,
1285
- "vader_compound": 0.0,
1286
- "vader_biased": False,
1287
- "confidence": 0.0,
1288
- "details": {"note": "Text too short for analysis"},
1289
- }
1290
-
1291
- text_lower = text.lower()
1292
-
1293
- # ── Generic keyword matching ──
1294
- right_hits = _count_keyword_hits(text_lower, RIGHT_LEANING_KEYWORDS)
1295
- left_hits = _count_keyword_hits(text_lower, LEFT_LEANING_KEYWORDS)
1296
- sensationalism_score, sensational_hits = _analyze_sensationalism(text_lower)
1297
- opinion_hits = _count_keyword_hits(text_lower, OPINION_MARKERS)
1298
- sensationalism_score += len(opinion_hits) * 3
1299
-
1300
- right_score = len(right_hits)
1301
- left_score = len(left_hits)
1302
-
1303
- # ── Philippine political figure scoring ──
1304
- conservative_score, admin_score, liberal_score, evidence_score, figure_hits = (
1305
- _score_political_figures(text_lower)
1306
- )
1307
-
1308
- # Fold figure scores into left/right totals
1309
- right_score += conservative_score + admin_score
1310
- left_score += liberal_score
1311
-
1312
- # Evidence-based writing can push toward centrist
1313
- centrist_boost = evidence_score
1314
-
1315
- # ── Unsourced claim / blind item detection ──
1316
- unsourced_score, unsourced_signals, attribution_count = _detect_unsourced_claim(
1317
- text_lower
1318
- )
1319
- unsourced_flag = unsourced_score >= 4 # threshold: 2+ signals with few attributions
1320
- if unsourced_flag:
1321
- sensationalism_score += 2 # unsourced claims are a sensationalism signal
1322
-
1323
- # ── Obfuscation / intentional misspell detection ──
1324
- obfuscation_score, obfuscation_signals = _detect_obfuscation(text)
1325
- obfuscated_flag = obfuscation_score >= 2
1326
- if obfuscated_flag:
1327
- sensationalism_score += min(3, obfuscation_score) # boost but cap contribution
1328
-
1329
- # ── Subjectivity ──
1330
- subjectivity = _compute_subjectivity(text)
1331
- subjectivity_flag = subjectivity > SUBJECTIVITY_THRESHOLD
1332
-
1333
- # ── VADER sentiment ──
1334
- vader_scores = _compute_vader_sentiment(text)
1335
- vader_compound = vader_scores["compound"]
1336
- VADER_BIAS_THRESHOLD = 0.5
1337
- vader_biased = abs(vader_compound) >= VADER_BIAS_THRESHOLD
1338
- if vader_biased:
1339
- sensationalism_score += 3
1340
-
1341
- # ── Pattern deviation boost (primary signal when available) ────────────────
1342
- # Deviation from reputable external sources is treated as a strong bias
1343
- # indicator: articles that add claims not found in any credible source are
1344
- # more likely to be biased or sensationalized, regardless of keyword matches.
1345
- pd_verdict = (pattern_deviation or {}).get("verdict", "")
1346
- pd_score = (pattern_deviation or {}).get("deviation_score", 0.0)
1347
- pd_confidence_boost = 0.0
1348
-
1349
- if pd_verdict == "SIGNIFICANT DEVIATION":
1350
- sensationalism_score += 3 # strong signal: adds claims no credible source reports
1351
- pd_confidence_boost = 0.08 # more certain about the detected leaning
1352
- elif pd_verdict == "MINOR DEVIATION":
1353
- sensationalism_score += 1 # mild signal
1354
-
1355
- # ── Determine leaning ──
1356
- total_political = right_score + left_score
1357
- bias_threshold = 3 # raised slightly since we now have more signal sources
1358
-
1359
- # Centrist boost: strong evidence markers counteract weak political scores
1360
- effective_political = max(0, total_political - centrist_boost)
1361
-
1362
- if effective_political < bias_threshold and sensationalism_score < 4:
1363
- leaning = "Centrist"
1364
- confidence = 0.6 + min(0.3, (bias_threshold - effective_political) * 0.1)
1365
- elif right_score > left_score:
1366
- # Distinguish Conservative (Duterte-camp) vs Admin/Right (Marcos-camp)
1367
- if conservative_score >= admin_score:
1368
- leaning = "Conservative"
1369
- else:
1370
- leaning = "Admin/Right"
1371
- diff = right_score - left_score
1372
- confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02 + pd_confidence_boost)
1373
- elif left_score > right_score:
1374
- leaning = "Left-leaning"
1375
- diff = left_score - right_score
1376
- confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02 + pd_confidence_boost)
1377
- else:
1378
- leaning = "Centrist"
1379
- confidence = 0.45 if sensationalism_score >= 4 else 0.7
1380
-
1381
- is_biased = (
1382
- (leaning != "Centrist")
1383
- or subjectivity_flag
1384
- or sensationalism_score >= 4
1385
- or vader_biased
1386
- or unsourced_flag
1387
- or obfuscated_flag
1388
- )
1389
-
1390
- return {
1391
- "leaning": leaning,
1392
- "is_biased": is_biased,
1393
- "unsourced_claim": unsourced_flag,
1394
- "unsourced_score": unsourced_score,
1395
- "obfuscated_text": obfuscated_flag,
1396
- "obfuscation_score": obfuscation_score,
1397
- "subjectivity": round(subjectivity, 4),
1398
- "subjectivity_flag": subjectivity_flag,
1399
- "vader_compound": round(vader_compound, 4),
1400
- "vader_biased": vader_biased,
1401
- "confidence": round(min(confidence, 1.0), 4),
1402
- "pattern_deviation_verdict": pd_verdict,
1403
- "deviation_score": round(pd_score, 4),
1404
- "details": {
1405
- "left_keywords": left_hits,
1406
- "right_keywords": right_hits,
1407
- "sensational_keywords": sensational_hits,
1408
- "opinion_markers": opinion_hits,
1409
- "unsourced_signals": unsourced_signals,
1410
- "attribution_count": attribution_count,
1411
- "obfuscation_signals": obfuscation_signals,
1412
- "figure_hits": figure_hits,
1413
- "left_score": left_score,
1414
- "right_score": right_score,
1415
- "conservative_score": conservative_score,
1416
- "admin_score": admin_score,
1417
- "liberal_score": liberal_score,
1418
- "evidence_score": evidence_score,
1419
- "sensationalism_score": sensationalism_score,
1420
- "pattern_deviation_verdict": pd_verdict,
1421
- "deviation_score": round(pd_score, 4),
1422
- },
1423
- }
 
1
+ """
2
+ Bias Analyzer β€” Heuristic-based political bias detection + subjectivity scoring
3
+ =================================================================================
4
+ Analyzes Filipino news articles for political bias using:
5
+ - Keyword patterns (left/right/sensational)
6
+ - Philippine political figure sentiment (2025-2026 context)
7
+ - Unsourced claim / "blind item" detection
8
+ - Opinion markers and subjectivity thresholds
9
+ - VADER sentiment scoring
10
+
11
+ Returns: Left-leaning, Right-leaning, Conservative, or Centrist.
12
+ Also flags unsourced claims (chismis / blind item style fake news).
13
+ """
14
+
15
+ import re
16
+ from textblob import TextBlob
17
+ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
18
+
19
+ # Shared VADER analyzer instance
20
+ _vader = SentimentIntensityAnalyzer()
21
+
22
+
23
+ # ── Philippine Political Figure Keywords (2025-2026 context) ────────────────
24
+ #
25
+ # The Philippine political landscape after 2024 has three main camps:
26
+ # CONSERVATIVE (Duterte faction) β€” Pro-Duterte, Anti-Marcos, Anti-Leni
27
+ # ADMIN/RIGHT (Marcos faction) β€” Pro-Marcos admin, critical of Duterte now
28
+ # LIBERAL/LEFT (Leni bloc) β€” Pro-Leni, Anti-Duterte, Anti-Marcos
29
+ #
30
+ # A CENTRIST does not side with personalities but examines credentials and policy.
31
+
32
+ # Phrases used by/about the DUTERTE FACTION (conservative/nationalist)
33
+ CONSERVATIVE_PRO = [
34
+ "tatay digong",
35
+ "digong",
36
+ "mayor duterte",
37
+ "president duterte",
38
+ "rody duterte",
39
+ "rodrigo duterte",
40
+ "war on drugs works",
41
+ "drug war effective",
42
+ "bong go",
43
+ "sara duterte is right",
44
+ "vp sara",
45
+ "baste duterte",
46
+ "dds",
47
+ "duterte die-hard supporter",
48
+ "pro-duterte",
49
+ "18 marines",
50
+ "federalismo",
51
+ "charter change ni duterte",
52
+ "the dutertes",
53
+ "duterte legacy",
54
+ "pagmamahal sa bansa",
55
+ "du30",
56
+ "DU30",
57
+ "DDS",
58
+ "Tatay Digong",
59
+ "πŸ‘Š",
60
+ "πŸ‘ŠπŸ‡΅πŸ‡­",
61
+ "Dilawan",
62
+ "Yellowtard",
63
+ "Lugaw",
64
+ "Lutang",
65
+ "Penoy",
66
+ "Oligarchs",
67
+ "Drug war",
68
+ "Build Build Build",
69
+ "Safety first",
70
+ "Strongman",
71
+ "Disiplina",
72
+ "Bayaran",
73
+ "Presinto",
74
+ "Fake news media",
75
+ "Biased",
76
+ "NPAs",
77
+ "Pula-pula",
78
+ "Aktibista",
79
+ "Woke",
80
+ "Moralista",
81
+ "Elitista",
82
+ "Matapang",
83
+ "Davao",
84
+ "Tapang at Malasakit",
85
+ "May gawa, hindi salita",
86
+ "Solid",
87
+ "Diehard",
88
+ "Puro reklamo",
89
+ "Sumunod na lang",
90
+ "Bakit hindi ka tumakbo?",
91
+ "Ambisyosa",
92
+ "Dating VP",
93
+ "Len-Len",
94
+ "Noynoying",
95
+ "Komunista",
96
+ "Katotohanan",
97
+ "DDS",
98
+ "Digong",
99
+ "Tatay Digong",
100
+ "PRRD",
101
+ "tapang at malasakit",
102
+ "Dutertards", # self-reclaimed by some supporters
103
+ "tokhang",
104
+ "war on drugs",
105
+ "change is coming",
106
+ "federalismo",
107
+ "BBL supporter",
108
+ "Build Build Build",
109
+ "Mindanaoan pride",
110
+ "Davao model",
111
+ "anti-dilawan",
112
+ "dilawan tayo", # used sarcastically against critics
113
+ "presyo ng bigas", # rice price talking point
114
+ "disente daw", # mockery of "decent" critics
115
+ "RP first",
116
+ "sovereign PH",
117
+ "OFW ni Digong",
118
+ "ICC out",
119
+ "ICC biased",
120
+ "CHR useless",
121
+ "media biased", # ABS-CBN franchise issue framing
122
+ "NTF-ELCAC",
123
+ "red-tagging justified",
124
+ "leftist agenda",
125
+ "destabilize",
126
+ "dilawang media",
127
+ "lugaw", # derogatory term for Robredo
128
+ "walang nagawa si Leni",
129
+ "Marcos-Duterte alliance",
130
+ "UniTeam loyalist",
131
+ "Hugpong",
132
+ "PDDS",
133
+ "Katas ng Saudi", # OFW remittance pride narrative
134
+ "drug lord protectors",
135
+ "yellows",
136
+ "Dutertista",
137
+ ]
138
+ CONSERVATIVE_ANTI = [
139
+ # Anti-Marcos (used by Duterte faction after their fallout)
140
+ "marcos incompetent",
141
+ "marcos palpak",
142
+ "marcos nagkanulo",
143
+ "ibaba si marcos",
144
+ "marcos ay traydor",
145
+ "marcos nagpalit ng tuktok",
146
+ "marcos dynasty",
147
+ "marcos diktador",
148
+ "bangag",
149
+ "Magnanakaw",
150
+ "BabyM",
151
+ "LBM",
152
+ "Sinungaling",
153
+ "Unity-ng-magnanakaw",
154
+ "Party President",
155
+ "Bangag",
156
+ "Cocaine-user",
157
+ "Dictator junior",
158
+ "Tax evader",
159
+ "Mandaraya",
160
+ "Golden Age (ironic)",
161
+ "Tallano Gold (mocking)",
162
+ "Traveler-in-Chief",
163
+ "Unity-is-scam",
164
+ "Historical distortor",
165
+ "Puppet of Romualdez",
166
+ "Weak leader",
167
+ "Walang nagawa",
168
+ "Incompetent",
169
+ "Flood control scandal",
170
+ "Trillion Peso March",
171
+ "Ill-gotten wealth",
172
+ "Martial law baby",
173
+ "Vlog-president",
174
+ "Absentee leader",
175
+ "Impeachment target",
176
+ "Marcos-crony",
177
+ "BBM-serye",
178
+ "Nepo-baby",
179
+ "Unite-troll",
180
+ "Bagong Pilipinas (mocking)",
181
+ "Maharlika-scam",
182
+ "Rice at 20 pesos (mocking)",
183
+ "Unity-over-justice",
184
+ "Puro party",
185
+ "Walang kusa",
186
+ "Fake diploma",
187
+ "Legacy-washer",
188
+ "Marcos-pa-rin (ironic)"
189
+ # Anti-Leni
190
+ "dilawan",
191
+ "kakampink",
192
+ "kakampwet",
193
+ "hontivirus",
194
+ "trilliling",
195
+ "leni lugaw",
196
+ "yellow army",
197
+ "kakampink talo",
198
+ "leni talo",
199
+ "leni sinungaling",
200
+ "leni bayad",
201
+ "aquino oligarch",
202
+ "oposisyon mapanlinlang",
203
+ "sentenaryo ng laban nila",
204
+ "Lugaw",
205
+ "Lutang",
206
+ "Pinktard",
207
+ "Kakampwet",
208
+ "NPA-sympathizer",
209
+ "Elitista",
210
+ "Matapobre",
211
+ "Moral high grounder",
212
+ "Dilawan",
213
+ "Yellowtard",
214
+ "Fake VP",
215
+ "Puppet of the US",
216
+ "Oligarch-funded",
217
+ "Puro dada",
218
+ "Self-righteous",
219
+ "Woke",
220
+ "Cancel culture",
221
+ "Leni-lenin",
222
+ "Komunista",
223
+ "Pabida",
224
+ "Pa-victim",
225
+ "Len-len",
226
+ "Madumb",
227
+ "Puppet",
228
+ "Lahat na lang mali",
229
+ "Neoliberal",
230
+ "Yellow-bleeding",
231
+ "Manila-centric",
232
+ "Hypocrite",
233
+ "The 1%",
234
+ "Walang alam",
235
+ "Staging photos",
236
+ "PR-driven",
237
+ "Puro puso walang utak",
238
+ "Kulto ni Leni",
239
+ "Privileged",
240
+ "Out of touch",
241
+ "Political loser",
242
+ "Ambisyosa",
243
+ "Bakit hindi ka tumakbo?",
244
+ "lugaw", # derogatory nickname for Robredo
245
+ "incompetente si Leni",
246
+ "walang nagawa si VP",
247
+ "plastic na pagmamahal",
248
+ "pang-epal lang",
249
+ "OVP budget sayang",
250
+ "pink propaganda",
251
+ "emotional voters",
252
+ "bobotante ng emosyon",
253
+ "dilawan ulit",
254
+ "yellowtard",
255
+ "Aquino alaga",
256
+ "Cory hangover",
257
+ "oligarko ng LP",
258
+ "LP trapo",
259
+ "Liberal Party corrupt",
260
+ "Robredo incompetent",
261
+ "lugaw republic",
262
+ "pabor sa NPA",
263
+ "komunistang suporta",
264
+ "abogadong walang alam",
265
+ "epal na relief ops",
266
+ "drama lang siya",
267
+ "intsik ang pondo", # baseless China-funding smear
268
+ "kakampink bobo",
269
+ "pink cult",
270
+ "sira ang isip ng pink",
271
+ "Leni talo na talo",
272
+ "loser si Leni",
273
+ "hindi electable",
274
+ "media darling lang",
275
+ "biased na survey",
276
+ "SWS peke",
277
+ "Pulse Asia bayad",
278
+ "mainstream media alaga",
279
+ "woke politics",
280
+ "imported agenda",
281
+ "western puppet",
282
+ "NGO funded Leni",
283
+ "traydor sa Visayas",
284
+ ]
285
+
286
+ # Phrases used by/about the MARCOS ADMINISTRATION faction
287
+ ADMIN_PRO = [
288
+
289
+ "uniteam",
290
+ "marcos legacy",
291
+ "bagong pilipinas",
292
+ "marcos economic plan",
293
+ "first lady liza",
294
+ "marcos right",
295
+ "marcos tama",
296
+ "marcos galing",
297
+ "marcos matino",
298
+ # NOTE: 'PBBM' and 'BBM' removed as standalone bias signals.
299
+ # These are neutral abbreviations used by ALL Philippine media to refer to
300
+ # the sitting president. Only biased contextual phrases are kept.
301
+ "UniTeam",
302
+ "❀️",
303
+ "✌️",
304
+ "Solid North",
305
+ "Bagong Pilipinas",
306
+ "Unity",
307
+ "Pagkakaisa",
308
+ "Marcosian",
309
+ "Loyalist",
310
+ "Tiger of the North",
311
+ "BBM-Sara",
312
+ "Economic recovery",
313
+ "Maharlika",
314
+ "Kadiwa",
315
+ "Legacy",
316
+ "Golden Age",
317
+ "Moving forward",
318
+ "Forgiveness",
319
+ "Negative campaigning",
320
+ "Paninira",
321
+ "Pinktard",
322
+ "Kakampwet",
323
+ "Uto-uto",
324
+ "Propaganda",
325
+ "Stability",
326
+ "Continuity",
327
+ "Diplomacy",
328
+ "Pres. Bongbong",
329
+ "First Family",
330
+ "Infrastructure",
331
+ "Modernization",
332
+ "Walang iwanan",
333
+ "Pilipinas Muna",
334
+ "Tigre",
335
+ "Pula",
336
+ "Bangon muli",
337
+ "Sama-sama",
338
+ "Bagong Pilipinas",
339
+ "UniTeam",
340
+ "Sara Duterte",
341
+ "Solid Marcos",
342
+ "Marcos pa rin",
343
+ "golden age ng Marcos",
344
+ "rice at P20",
345
+ "mahirap pag-usapan ang kasaysayan", # revisionism framing
346
+ "historical revisionism daw",
347
+ "Ilocano pride",
348
+ "Solid North",
349
+ "MarcosLegacy",
350
+ "hindi communista",
351
+ "federalismo ni BBM",
352
+ "infra development",
353
+ "GNP growth",
354
+ "Marcos era golden years",
355
+ "Tallano gold", # fringe pro-Marcos wealth myth
356
+ "Yamashita treasure",
357
+ "opposition nagrereklamo lang",
358
+ "probinsya first",
359
+ "sariling atin", # economic nationalism framing
360
+ "Maharlika Fund",
361
+ "Marcos hindi magnanakaw",
362
+ "rehabilitasyon ni Marcos",
363
+ "marcos loyalista",
364
+ "bisita Marcos museum",
365
+ "admin side",
366
+ "Malacanang suporta",
367
+ "BBM-Sara tandem",
368
+ ]
369
+ ADMIN_ANTI = [
370
+ # Anti-Duterte (used by Marcos camp after fallout)
371
+ "duterte corrupt",
372
+ "duterte accountability",
373
+ "duterte icc",
374
+ "duterte drug lord",
375
+ "duterte criminal",
376
+ "arrest duterte",
377
+ "duterte drug syndicate",
378
+ "duterte narco",
379
+ ]
380
+
381
+ # Phrases used by/about the LIBERAL/LEFT (Leni bloc)
382
+ LIBERAL_PRO = [
383
+ "leni robredo",
384
+ "si leni",
385
+ "leni honest",
386
+ "leni tapat",
387
+ "angat buhay ni leni",
388
+ "kakampink",
389
+ "kay leni",
390
+ "kiko pangilinan",
391
+ "leila de lima",
392
+ "liberals tama",
393
+ "leni integrity",
394
+ "leni credentials",
395
+ "leni public service",
396
+ "pink movement",
397
+ "leni sincere",
398
+ "walang corrupt si leni",
399
+ "laban ng masa",
400
+ "makabayan bloc",
401
+ "progressive",
402
+ "Kakampink",
403
+ "Leni-Kiko",
404
+ "πŸ’—",
405
+ "🌸",
406
+ "Angat Buhay",
407
+ "Kulay Rosas",
408
+ "Husay at Tibay",
409
+ "Gobyernong Tapat",
410
+ "Radikal ang Magmahal",
411
+ "Resibo",
412
+ "Fact-check",
413
+ "Trolls",
414
+ "Magnanakaw",
415
+ "Dictator",
416
+ "Historical distortion",
417
+ "Unity is not enough",
418
+ "Walang Solid North",
419
+ "Bardagulan",
420
+ "Tumindig",
421
+ "Para sa Bansa",
422
+ "Volunteers",
423
+ "Pink Movement",
424
+ "Liwanag sa Dilim",
425
+ "Laban Leni",
426
+ "Let Leni Lead",
427
+ "OVP",
428
+ "Disinformation",
429
+ "Tallano Gold",
430
+ "BBM",
431
+ "LBM",
432
+ "Sinungaling",
433
+ "Tax evader",
434
+ "Mulat",
435
+ "Kababaihan",
436
+ "Youth vote",
437
+ "Para sa kinabukasan",
438
+ "Transparency",
439
+ "Accountability",
440
+ "Edukado",
441
+ "Ipanalo Natin 'To",
442
+ "Kakampink",
443
+ "Leni Lugaw", # reclaimed term of endearment
444
+ "Kay Leni tayo",
445
+ "Pink movement",
446
+ "Pink volunteer",
447
+ "Angat Buhay",
448
+ "OVP proud",
449
+ "VP Leni",
450
+ "Team Leni",
451
+ "masa tayo",
452
+ "puso ng serbisyo",
453
+ "bobotante tayo",
454
+ "dilaw", # reclaimed yellow color symbol
455
+ "totoong pagbabago",
456
+ "Cory legacy",
457
+ "EDSA spirit",
458
+ "Never Again",
459
+ "Martial Law victims",
460
+ "human rights first",
461
+ "CHR matters",
462
+ "fact check muna",
463
+ "propaganda detector",
464
+ "akademiko tayo",
465
+ "Robredo admin",
466
+ "Vote wisely",
467
+ "hindi pa huli",
468
+ "madaming puso",
469
+ "serbisyong totoo",
470
+ "Comelec transparency",
471
+ "kontra sa dayaan",
472
+ "boto natin 'to",
473
+ "PinkLaban",
474
+ "pag-asa ng bayan",
475
+ "bukas na isip",
476
+ "anti-red-tagging",
477
+ "INC split", # electoral coalition discussion
478
+ "masa over masa",
479
+ "Leody-Leni",
480
+ "sama-sama tayo",
481
+ "Laban Leni",
482
+ ]
483
+ LIBERAL_ANTI = [
484
+ # Anti-Duterte (mainstay of liberal discourse)
485
+ "extrajudicial killing",
486
+ "ejk",
487
+ "ej killing",
488
+ "duterte human rights",
489
+ "duterte impunity",
490
+ "duterte murders",
491
+ "drug war victims",
492
+ "tokhang victims",
493
+ "Dutertard",
494
+ "DDS-troll",
495
+ "Fentanyl",
496
+ "Davao Group",
497
+ "ICC-bound",
498
+ "Traydor",
499
+ "Kill kill kill",
500
+ "EJK-enabler",
501
+ "False prophet",
502
+ "Budol",
503
+ "Wumao",
504
+ "China-doll",
505
+ "POGO-protector",
506
+ "Kulto",
507
+ "Brainwashed",
508
+ "Violence-prone",
509
+ "Davao Dynasty",
510
+ "Mamatay-tao",
511
+ "Bastos",
512
+ "Digong-iyakin",
513
+ "Paid trolls",
514
+ "Fake news peddler",
515
+ "Tuta ng China",
516
+ "Digunggong",
517
+ "Bisakol (derogatory context)",
518
+ "Confidential funds queen",
519
+ "Impeachable",
520
+ "Threat to democracy",
521
+ "War on drugs failure",
522
+ "Galing Davao",
523
+ "Siga-siga",
524
+ "No respect for law",
525
+ "Human rights violator",
526
+ "Extra-judicial",
527
+ "Blind follower",
528
+ "DDS-shaming",
529
+ "Toxic",
530
+ "Divisionist",
531
+ "Feudal",
532
+ "Oust-Duterte",
533
+ "Dutertador", # Duterte + diktador
534
+ "diktador",
535
+ "pasistang rehimen",
536
+ "state-sponsored killing",
537
+ "EJK", # extra-judicial killing
538
+ "extra-judicial killing",
539
+ "dead on arrival", # EJK slang
540
+ "drug war patay",
541
+ "salvage victim",
542
+ "patayin ang mahirap",
543
+ "tokhang para sa mayaman",
544
+ "tulak pababa", # pushing down the poor
545
+ "gulat na gulat", # ironic shock at impunity
546
+ "impunidad",
547
+ "ICC isampa", # ICC filing demand
548
+ "idemanda si Duterte",
549
+ "war criminal",
550
+ "Davao Death Squad",
551
+ "DDS killer",
552
+ "Duterte resign",
553
+ "Duterte pababa",
554
+ "diktadurang Duterte",
555
+ "pinaslang",
556
+ "Duterte drug lord",
557
+ "narco list fake",
558
+ "red-tagging biktima",
559
+ "NTF-ELCAC terorista",
560
+ "baril batas", # "gun is the law" critique
561
+ "walang due process",
562
+ "peke na pagbabago",
563
+ "tuta ng China", # China appeasement critique
564
+ "nagbenta ng teritoryo",
565
+ "West Philippine Sea talunan",
566
+ "basura na pangako",
567
+ "sinungaling si Duterte",
568
+ "ABS-CBN shutdown",
569
+ "franchise denied",
570
+ "presyo tumaas",
571
+ "gutom sa ilalim ni Duterte",
572
+ "human rights patay",
573
+ # Anti-Marcos
574
+ "never forget martial law",
575
+ "hindi makakalimot",
576
+ "marcos plunder",
577
+ "marcos ill-gotten wealth",
578
+ "marcos human rights abuses",
579
+ "batas militar",
580
+ "martial law victims",
581
+ "diktadurang Marcos",
582
+ "Never Again Marcos",
583
+ "Martial Law atrocities",
584
+ "Marcos diktador",
585
+ "Ferdinand Marcos magnanakaw",
586
+ "ill-gotten wealth",
587
+ "nakawin ng Marcos",
588
+ "Marcos thief",
589
+ "PCGG asset",
590
+ "ibalik ang ninakaw",
591
+ "human rights abuses",
592
+ "desaparecido",
593
+ "torture victim Marcos",
594
+ "1081 batas militar",
595
+ "September 21 hindi kalimutan",
596
+ "Marcos burial laban",
597
+ "LNMB disgrasya",
598
+ "historical revisionist",
599
+ "fake news Marcos",
600
+ "propaganda ng Marcos",
601
+ "BBM bobo",
602
+ "BBM pasang-awa",
603
+ "grade fake BBM",
604
+ "Oxford dropout",
605
+ "hindi nagtapos",
606
+ "plunder case Marcos",
607
+ "tax evasion Marcos",
608
+ "hindi nagbayad ng buwis",
609
+ "Maharlika Fund scam",
610
+ "MIF panganib",
611
+ "Marcos crony",
612
+ "Enrile kasama",
613
+ "Sara Duterte traydor",
614
+ "UniTeam nagkahiwalay",
615
+ "Marcos-Duterte gulo",
616
+ "Solid North bulok",
617
+ "admin corruption",
618
+ "Malacanang sinungaling",
619
+ "BBM resign",
620
+ "Marcos pababa",
621
+ ]
622
+
623
+ # Centrist / evidence-based signals
624
+ EVIDENCE_BASED_MARKERS = [
625
+ "ayon sa datos",
626
+ "according to official records",
627
+ "based on data",
628
+ "per official report",
629
+ "according to the commission",
630
+ "sinabi ng opisyal",
631
+ "ayon sa pag-aaral",
632
+ "studies show",
633
+ "data shows",
634
+ "statistics show",
635
+ "according to the survey",
636
+ "sinabi ng eksperto",
637
+ "expert said",
638
+ "peer-reviewed",
639
+ "official statement",
640
+ "press release",
641
+ "on record",
642
+ "verified by",
643
+ "fact-checked",
644
+ "source:",
645
+ "sources:",
646
+ "according to",
647
+ "as reported by",
648
+ "per the department",
649
+ "ayon sa palasyo",
650
+ "ayon sa doj",
651
+ "ayon sa comelec",
652
+ "ayon sa ched",
653
+ "ayon sa doh",
654
+ "ayon sa deped",
655
+ "transcript shows",
656
+ "court records",
657
+ "senate hearing",
658
+ "house hearing",
659
+ "plunder case",
660
+ "graft case",
661
+ "ombudsman",
662
+ ]
663
+
664
+
665
+ # ── Generic Left/Right Keywords (broader patterns) ───────────────────────────
666
+
667
+ RIGHT_LEANING_KEYWORDS = [
668
+ "nagtagumpay",
669
+ "matagumpay",
670
+ "malaking tulong",
671
+ "pagbabago",
672
+ "disiplina",
673
+ "drug war",
674
+ "malasakit",
675
+ "build build build",
676
+ "matuwid",
677
+ "natatapos",
678
+ "pangako",
679
+ "nagpapatupad",
680
+ "achievements",
681
+ "anti-drug",
682
+ "anti-crime",
683
+ "peace and order",
684
+ "development",
685
+ "ekonomiya",
686
+ "destabilize",
687
+ "destabilization",
688
+ "oposisyon",
689
+ "anti-government",
690
+ "bias media",
691
+ "oligarch",
692
+ "propagandist",
693
+ "sovereignty",
694
+ "soberanya",
695
+ "traitor",
696
+ "worst traitor",
697
+ "national interest",
698
+ "patriotism",
699
+ "nationalist",
700
+ "faithful servant",
701
+ "servant of the people",
702
+ "wrongly accused",
703
+ "political persecution",
704
+ "political prisoner",
705
+ "legacy",
706
+ "icc persecution",
707
+ "he murdered no one",
708
+ "presumed innocent",
709
+ ]
710
+
711
+ LEFT_LEANING_KEYWORDS = [
712
+ "human rights",
713
+ "extrajudicial",
714
+ "ej killing",
715
+ "ejk",
716
+ "impunity",
717
+ # 'corruption'/'korapsyon' removed β€” used by ALL political sides, causes false positives
718
+ # 'accountability'/'transparency' removed β€” same reason (universal talking points)
719
+ "press freedom",
720
+ "authoritarian",
721
+ "dictator",
722
+ "diktador",
723
+ "martial law",
724
+ "red-tagging",
725
+ "red tagging",
726
+ "activists",
727
+ "workers rights",
728
+ "labor rights",
729
+ "progressive",
730
+ "inequality",
731
+ "social justice",
732
+ "democratic space",
733
+ "civil liberties",
734
+ "dissent",
735
+ "political prisoners",
736
+ # Specific to liberal/left PH discourse
737
+ "disinformation", # kept β€” used more as anti-fake-news framing by left
738
+ "state-sponsored killing",
739
+ "oust",
740
+ "resign",
741
+ ]
742
+
743
+
744
+ # ── Unsourced Claim / "Blind Item" Signals ───────────────────────────────────
745
+ #
746
+ # Fake news on Philippine Facebook often mimics showbiz blind items or
747
+ # "chismis" posts: dramatic claims made without named sources, often ending
748
+ # with prompts to share before the post is deleted.
749
+
750
+ UNSOURCED_CLAIM_SIGNALS = [
751
+ # Filipino hearsay markers β€” claim made but no named source
752
+ "daw",
753
+ "raw",
754
+ "diumano",
755
+ "umano",
756
+ "may nagsabi",
757
+ "may nagbalita",
758
+ "may nag-ulat",
759
+ "may nagpadala sa akin",
760
+ "may nagbigay ng impormasyon",
761
+ "may kilala akong",
762
+ "kilala ko ang nagbigay",
763
+ "mula sa isang pinagkakatiwalaang tao",
764
+ "mula sa reliable na source",
765
+ "reliable ang source",
766
+ "hindi ko maaaring ibigay ang pangalan",
767
+ "hindi maaaring ibunyag ang pangalan",
768
+ "hindi ko masasabi kung sino",
769
+ "ipinadala sa akin ng isang kaibigan",
770
+ # Verification-prompting phrases (signals the poster doubts authenticity)
771
+ "totoo kaya ito",
772
+ "tama ba ito",
773
+ "kung totoo ito",
774
+ "pag-isipan ninyo",
775
+ "nakaka-isip",
776
+ "nagtataka ako",
777
+ # Viral / share-baiting language
778
+ "share before deleted",
779
+ "i-share bago i-delete",
780
+ "share na bago tanggalin",
781
+ "bago pa tanggalin",
782
+ "delete na ito agad",
783
+ "wag palampasin",
784
+ "must share",
785
+ "share this now",
786
+ "share niyo",
787
+ "share before they take this down",
788
+ # Anonymous-source framing
789
+ "anonymous source",
790
+ "blind item",
791
+ "from a reliable source",
792
+ "sources say",
793
+ "sources told",
794
+ "a source revealed",
795
+ # English hearsay
796
+ "i heard that",
797
+ "i was told that",
798
+ "apparently",
799
+ "it is said that",
800
+ "rumor has it",
801
+ "parang totoo",
802
+ "unconfirmed reports",
803
+ "unverified",
804
+ "hindi pa kumpirmado",
805
+ "hindi pa confirmed",
806
+ "balita lang",
807
+ "chismis lang ito",
808
+ ]
809
+
810
+ # Attribution markers β€” when PRESENT they REDUCE the unsourced claim score
811
+ ATTRIBUTION_MARKERS = [
812
+ "ayon sa",
813
+ "sabi ni",
814
+ "according to",
815
+ "stated by",
816
+ "as reported by",
817
+ "cited by",
818
+ "based on",
819
+ "sinabi ni",
820
+ "pahayag ni",
821
+ "per ",
822
+ "per president",
823
+ "per department",
824
+ "sinabi ng",
825
+ "inihayag ni",
826
+ "ipinagpaliwanag ni",
827
+ "idineklara ni",
828
+ "isinagawa ng",
829
+ "pinayagan ni",
830
+ "nagpahayag si",
831
+ "nagbigay ng pahayag si",
832
+ "nagsalita si",
833
+ "said ",
834
+ "announced ",
835
+ "confirmed ",
836
+ "stated ",
837
+ "declared ",
838
+ "officials say",
839
+ "government said",
840
+ "department said",
841
+ "report says",
842
+ "study says",
843
+ "survey says",
844
+ ]
845
+
846
+
847
+ # ── Sensationalism / Emotional Charge ────────────────────────────────────────
848
+
849
+ SENSATIONAL_KEYWORDS = [
850
+ # Filipino
851
+ "grabe",
852
+ "sobrang",
853
+ "nakakatakot",
854
+ "nakakalungkot",
855
+ "kadiri",
856
+ "nakakagalit",
857
+ "buwag",
858
+ "gago",
859
+ "bobo",
860
+ "sinungaling",
861
+ "traydor",
862
+ "pahamak",
863
+ "palpak",
864
+ "basura",
865
+ "walang silbi",
866
+ "panalo",
867
+ "astig",
868
+ "idol",
869
+ "legend",
870
+ "must read",
871
+ "must watch",
872
+ "share",
873
+ "viral",
874
+ "breaking",
875
+ "just in",
876
+ "urgent",
877
+ "adik",
878
+ "NAPAKA",
879
+ # English β€” emotional/sensational
880
+ "shocking",
881
+ "outrageous",
882
+ "disgusting",
883
+ "horrifying",
884
+ "terrifying",
885
+ "devastating",
886
+ "despicable",
887
+ "abominable",
888
+ "deplorable",
889
+ "wickedness",
890
+ "depraved",
891
+ "stench",
892
+ "satan",
893
+ "evil",
894
+ "fire and fury",
895
+ "pipe dream",
896
+ "diatribe",
897
+ "harangue",
898
+ "perfidious",
899
+ "despairingly",
900
+ "intolerable",
901
+ "condemning",
902
+ "conspired",
903
+ "cronies",
904
+ "sham",
905
+ "scam",
906
+ "hoax",
907
+ "unbelievable",
908
+ "wake up",
909
+ "exposed",
910
+ "betrayed",
911
+ "rigged",
912
+ # Ad hominem
913
+ "vile",
914
+ "cunning",
915
+ "snake",
916
+ "poisoned",
917
+ "worst traitor",
918
+ "go down in history",
919
+ "day of reckoning",
920
+ "alarming",
921
+ "dangerous",
922
+ "coward",
923
+ "liar",
924
+ "hypocrite",
925
+ "shameless",
926
+ "corrupt",
927
+ "criminal",
928
+ "thief",
929
+ "incompetent",
930
+ "puppet",
931
+ "lapdog",
932
+ "traitor",
933
+ "backstabber",
934
+ "sellout",
935
+ "bankrupt",
936
+ "monster",
937
+ "villain",
938
+ "enemy of the people",
939
+ "enemy of the state",
940
+ "kakampi ng kaaway",
941
+ "tuta",
942
+ "duwag",
943
+ "magnanakaw",
944
+ "masamang tao",
945
+ ]
946
+
947
+ # First-person / direct opinion markers
948
+ OPINION_MARKERS = [
949
+ "in my opinion",
950
+ "in my assessment",
951
+ "in my view",
952
+ "i believe",
953
+ "i think",
954
+ "my legal opinion",
955
+ "my honest thoughts",
956
+ "i personally",
957
+ "from my perspective",
958
+ "i am inclined to believe",
959
+ "i am inclined",
960
+ "i pray",
961
+ "i am almost certain",
962
+ "i am certain",
963
+ "i am convinced",
964
+ "i suspect",
965
+ "i feel",
966
+ "i hope",
967
+ "we must",
968
+ "we should",
969
+ "it is my",
970
+ "this writer believes",
971
+ "this columnist",
972
+ "in this writer",
973
+ "i have no doubt",
974
+ "without a doubt",
975
+ "clearly",
976
+ "obviously",
977
+ "needless to say",
978
+ # Filipino first-person
979
+ "sa aking palagay",
980
+ "sa tingin ko",
981
+ "naniniwala ako",
982
+ "sa aking paniniwala",
983
+ ]
984
+
985
+ SUBJECTIVITY_THRESHOLD = 0.50
986
+
987
+
988
+ # ── Helper functions ──────────────────────────────────────────────────────────
989
+
990
+
991
+ def _count_keyword_hits(text_lower, keyword_list):
992
+ """Count how many keywords from the list appear in the text."""
993
+ hits = []
994
+ for kw in keyword_list:
995
+ if kw.lower() in text_lower:
996
+ hits.append(kw)
997
+ return hits
998
+
999
+
1000
+ def _analyze_sensationalism(text_lower):
1001
+ """Detect sensationalist language patterns."""
1002
+ hits = _count_keyword_hits(text_lower, SENSATIONAL_KEYWORDS)
1003
+ exclamation_count = text_lower.count("!")
1004
+ question_chains = len(re.findall(r"\?{2,}", text_lower))
1005
+ caps_words = len(re.findall(r"\b[A-Z]{3,}\b", text_lower))
1006
+
1007
+ score = len(hits) * 2 + min(exclamation_count, 5) + question_chains * 2 + caps_words
1008
+ return score, hits
1009
+
1010
+
1011
+ def _detect_unsourced_claim(text_lower):
1012
+ """Detect 'blind item' / chismis-style unsourced claims.
1013
+
1014
+ Returns (score, signals_found, attribution_count).
1015
+ High score + low attribution = likely unsourced fake news style.
1016
+ """
1017
+ signals = _count_keyword_hits(text_lower, UNSOURCED_CLAIM_SIGNALS)
1018
+ attributions = _count_keyword_hits(text_lower, ATTRIBUTION_MARKERS)
1019
+
1020
+ # Raw score: each unsourced signal +2, each attribution -1 (floor 0)
1021
+ raw = len(signals) * 2
1022
+ credit = len(attributions)
1023
+ score = max(0, raw - credit)
1024
+ return score, signals, len(attributions)
1025
+
1026
+
1027
+ def _detect_obfuscation(text):
1028
+ """Detect intentional misspelling / character substitution used to evade
1029
+ content moderation filters.
1030
+
1031
+ Fake news peddlers on Philippine social media commonly:
1032
+ 1. Replace letters with look-alike symbols: \u0394\u2192a, @\u2192a inside words
1033
+ 2. Use leet-speak digits: 3\u2192e, 0\u2192o, 1\u2192i inside words
1034
+ 3. Phonetically misspell: 'manga' for 'mga', 'panu' for 'paano'
1035
+ 4. Excessive ALL-CAPS beyond known government acronyms
1036
+
1037
+ Returns (score, signals_found). Score >= 2 is a reliable flag.
1038
+ """
1039
+ signals = []
1040
+
1041
+ # 1. Explicit character substitutions β€” each hit is worth 2 points
1042
+ # (a single substitution is definitive; no need to wait for 2 signals)
1043
+ explicit_score = 0
1044
+
1045
+ # Delta family: U+2206 INCREMENT βˆ†, U+0394 GREEK DELTA Ξ”,
1046
+ # U+25B3 WHITE TRIANGLE β–³, U+25B2 BLACK TRIANGLE β–²
1047
+ DELTA_CHARS = "\u2206\u0394\u25b3\u25b2"
1048
+ delta_hits = sum(text.count(c) for c in DELTA_CHARS)
1049
+ if delta_hits:
1050
+ signals.append(f"\u2206/\u0394 used as letter \u00d7 {delta_hits}")
1051
+ explicit_score += 2
1052
+
1053
+ # Degree symbol \u00b0 used as 'o' (e.g. 'tunt\u00b0' = 'tunto')
1054
+ degree_hits = text.count("\u00b0")
1055
+ if degree_hits:
1056
+ signals.append(f"\u00b0 used as letter \u00d7 {degree_hits}")
1057
+ explicit_score += 2
1058
+
1059
+ # @ inside a word as 'a' substitution (not an email address)
1060
+ at_in_word = len(re.findall(r"\w@\w", text))
1061
+ if at_in_word:
1062
+ signals.append(f"@ inside word \u00d7 {at_in_word}")
1063
+ explicit_score += 2
1064
+
1065
+ # Other single-char Latin letter substitutions: Β‘\u2192i, \u00f8\u2192o, \u00a5\u2192y, \u20ac\u2192e
1066
+ for ch, label in [
1067
+ ("\u00a1", "\u00a1\u2192i"),
1068
+ ("\u00f8", "\u00f8\u2192o"),
1069
+ ("\u00a5", "\u00a5\u2192y"),
1070
+ ("\u20ac", "\u20ac\u2192e"),
1071
+ ("\u00a2", "\u00a2\u2192c"),
1072
+ ("\u00a3", "\u00a3\u2192b"),
1073
+ ]:
1074
+ if ch in text:
1075
+ signals.append(f"'{label}' substitution")
1076
+ explicit_score += 2
1077
+
1078
+ # Generic non-ASCII scan (weak signal β€” only add if no explicit sub found)
1079
+ non_ascii = len(re.findall(r"[^\x00-\x7F]", text))
1080
+ if non_ascii > 0 and explicit_score == 0 and 1 < non_ascii <= 20:
1081
+ signals.append(f"non-ASCII chars in Latin text \u00d7 {non_ascii}")
1082
+
1083
+ # 2. Leet-speak: digits inside words (e.g. 'm4rcos', '3JK')
1084
+ leet_words = [
1085
+ w for w in re.findall(r"\b\w*[30145]\w*\b", text) if re.search(r"[a-zA-Z]", w)
1086
+ ]
1087
+ if len(leet_words) >= 2:
1088
+ signals.append(f"leet digits in words: {leet_words[:4]}")
1089
+
1090
+ # 3. Known Filipino phonetic misspells / slang markers
1091
+ PHONETIC = [
1092
+ (r"\bmanga\b", "'manga' for 'mga'"),
1093
+ (r"\bkau\b", "'kau' for 'kayo'"),
1094
+ (r"\bnman\b", "'nman' for 'naman'"),
1095
+ (r"\bpanu\b", "'panu' for 'paano'"),
1096
+ (r"\bganun\b", "'ganun' for 'ganoon'"),
1097
+ (r"\bbeh\b", "'beh' informal"),
1098
+ (r"\bteh\b", "'teh' informal"),
1099
+ (r"\bchar\b", "'char' joke marker"),
1100
+ (r"\bOMG\b", "'OMG' exclamation"),
1101
+ (r"\bWTF\b", "profane exclamation"),
1102
+ ]
1103
+ tl = text.lower()
1104
+ for pat, label in PHONETIC:
1105
+ if re.search(pat, tl):
1106
+ signals.append(label)
1107
+
1108
+ # 4. Excessive ALL-CAPS beyond standard government acronyms
1109
+ LEGIT_CAPS = {
1110
+ "PNP",
1111
+ "AFP",
1112
+ "DOH",
1113
+ "COMELEC",
1114
+ "CHR",
1115
+ "DOJ",
1116
+ "OMB",
1117
+ "DILG",
1118
+ "DSWD",
1119
+ "CHED",
1120
+ "DEPED",
1121
+ "DOE",
1122
+ "DBM",
1123
+ "BIR",
1124
+ "PSA",
1125
+ "BSP",
1126
+ "SEC",
1127
+ "LTO",
1128
+ "LTFRB",
1129
+ "MMDA",
1130
+ "NBI",
1131
+ "NASA",
1132
+ "FBI",
1133
+ "CIA",
1134
+ "USA",
1135
+ "PH",
1136
+ "OVP",
1137
+ "ICC",
1138
+ "DNA",
1139
+ "HIV",
1140
+ "WHO",
1141
+ "WFP",
1142
+ "ASEAN",
1143
+ "UN",
1144
+ "DDS",
1145
+ "BBM",
1146
+ "PBBM",
1147
+ "VP",
1148
+ "SWS",
1149
+ "PCSO",
1150
+ "PAGCOR",
1151
+ "EJK",
1152
+ "PRRD",
1153
+ "AFP",
1154
+ }
1155
+ suspicious_caps = [
1156
+ w for w in re.findall(r"\b[A-Z]{3,}\b", text) if w not in LEGIT_CAPS
1157
+ ]
1158
+ if len(suspicious_caps) >= 3:
1159
+ signals.append(f"excessive ALL-CAPS: {suspicious_caps[:5]}")
1160
+ # Heavy ALL-CAPS (β‰₯ 8 non-acronym caps words) alone is a strong social-media
1161
+ # obfuscation signal β€” real journalism never shouts entire sentences in caps.
1162
+ # Boost explicit_score so the flag triggers even without βˆ†/leet/@ substitutions.
1163
+ if len(suspicious_caps) >= 8:
1164
+ explicit_score += 2
1165
+
1166
+ score = explicit_score + len(signals) - (1 if explicit_score > 0 else 0)
1167
+ # ^ explicit hits already added to score; subtract 1 to avoid counting their
1168
+ # signal string twice (each explicit hit already contributes to len(signals))
1169
+ # Simplify: final score = explicit_score + non-explicit signal count
1170
+ non_explicit_signals = [
1171
+ s
1172
+ for s in signals
1173
+ if not any(
1174
+ k in s
1175
+ for k in ["\u2206", "\u0394", "\u00b0", "@", "substitution", "non-ASCII"]
1176
+ )
1177
+ ]
1178
+ score = explicit_score + len(non_explicit_signals)
1179
+ return score, signals
1180
+
1181
+
1182
+ def _compute_subjectivity(text):
1183
+ """Compute subjectivity score using TextBlob (0=objective, 1=subjective)."""
1184
+ try:
1185
+ return TextBlob(text).sentiment.subjectivity
1186
+ except Exception:
1187
+ return 0.0
1188
+
1189
+
1190
+ def _compute_vader_sentiment(text):
1191
+ """Compute VADER sentiment intensity."""
1192
+ try:
1193
+ return _vader.polarity_scores(text)
1194
+ except Exception:
1195
+ return {"compound": 0.0, "pos": 0.0, "neu": 1.0, "neg": 0.0}
1196
+
1197
+
1198
+ def _score_political_figures(text_lower):
1199
+ """Score political figure sentiment for Philippine context.
1200
+
1201
+ Returns (conservative_score, admin_score, liberal_score, figure_hits).
1202
+ """
1203
+ cons_pro = _count_keyword_hits(text_lower, CONSERVATIVE_PRO)
1204
+ cons_anti = _count_keyword_hits(text_lower, CONSERVATIVE_ANTI)
1205
+ adm_pro = _count_keyword_hits(text_lower, ADMIN_PRO)
1206
+ adm_anti = _count_keyword_hits(text_lower, ADMIN_ANTI)
1207
+ lib_pro = _count_keyword_hits(text_lower, LIBERAL_PRO)
1208
+ lib_anti = _count_keyword_hits(text_lower, LIBERAL_ANTI)
1209
+ evidence = _count_keyword_hits(text_lower, EVIDENCE_BASED_MARKERS)
1210
+
1211
+ # Net scores: positive praise - negative criticism signals the faction leaning
1212
+ conservative_score = len(cons_pro) * 2 + len(cons_anti) * 1
1213
+ admin_score = len(adm_pro) * 2 + len(adm_anti) * 1
1214
+ liberal_score = len(lib_pro) * 2 + len(lib_anti) * 1
1215
+ evidence_score = len(evidence)
1216
+
1217
+ figure_hits = {
1218
+ "conservative_pro": cons_pro,
1219
+ "conservative_anti": cons_anti,
1220
+ "admin_pro": adm_pro,
1221
+ "admin_anti": adm_anti,
1222
+ "liberal_pro": lib_pro,
1223
+ "liberal_anti": lib_anti,
1224
+ "evidence_markers": evidence,
1225
+ }
1226
+ return conservative_score, admin_score, liberal_score, evidence_score, figure_hits
1227
+
1228
+
1229
+ # ── Main function ─────────────────────────────────────────────────────────────
1230
+
1231
+
1232
+ def analyze_bias(text, pattern_deviation=None, lang="en"):
1233
+
1234
+ """Analyze a news article for political bias and subjectivity.
1235
+
1236
+ Args:
1237
+ text (str): Article text.
1238
+ pattern_deviation (dict|None): Result from compare_to_external_pattern().
1239
+ When provided, deviation from reputable sources directly boosts
1240
+ sensationalism score and leaning confidence β€” making pattern deviation
1241
+ a primary signal rather than a post-hoc annotation.
1242
+
1243
+ Returns:
1244
+ dict with keys:
1245
+ leaning: "Conservative", "Admin/Right", "Left-leaning", or "Centrist"
1246
+ is_biased: bool β€” True if clearly leaning or sensational
1247
+ unsourced_claim: bool β€” True if article appears to be a blind item / chismis
1248
+ unsourced_score: int β€” raw unsourced claim score
1249
+ subjectivity: float (0-1)
1250
+ subjectivity_flag: bool
1251
+ vader_compound: float
1252
+ vader_biased: bool
1253
+ confidence: float (0-1)
1254
+ pattern_deviation_verdict: str β€” "FOLLOWS PATTERN", "MINOR DEVIATION", "SIGNIFICANT DEVIATION", or ""
1255
+ deviation_score: float β€” 0.0–1.0 fraction of sentences not in reputable sources
1256
+ details: dict with full breakdown
1257
+ """
1258
+ if not text or not isinstance(text, str) or len(text.strip()) < 20:
1259
+ return {
1260
+ "leaning": "Centrist",
1261
+ "is_biased": False,
1262
+ "unsourced_claim": False,
1263
+ "unsourced_score": 0,
1264
+ "obfuscated_text": False,
1265
+ "obfuscation_score": 0,
1266
+ "subjectivity": 0.0,
1267
+ "subjectivity_flag": False,
1268
+ "vader_compound": 0.0,
1269
+ "vader_biased": False,
1270
+ "confidence": 0.0,
1271
+ "details": {"note": "Text too short for analysis"},
1272
+ }
1273
+
1274
+ text_lower = text.lower()
1275
+
1276
+ # ── Generic keyword matching ──
1277
+ right_hits = _count_keyword_hits(text_lower, RIGHT_LEANING_KEYWORDS)
1278
+ left_hits = _count_keyword_hits(text_lower, LEFT_LEANING_KEYWORDS)
1279
+ sensationalism_score, sensational_hits = _analyze_sensationalism(text_lower)
1280
+ opinion_hits = _count_keyword_hits(text_lower, OPINION_MARKERS)
1281
+ sensationalism_score += len(opinion_hits) * 3
1282
+
1283
+ right_score = len(right_hits)
1284
+ left_score = len(left_hits)
1285
+
1286
+ # ── Philippine political figure scoring ──
1287
+ conservative_score, admin_score, liberal_score, evidence_score, figure_hits = (
1288
+ _score_political_figures(text_lower)
1289
+ )
1290
+
1291
+ # Fold figure scores into left/right totals
1292
+ right_score += conservative_score + admin_score
1293
+ left_score += liberal_score
1294
+
1295
+ # Evidence-based writing can push toward centrist
1296
+ centrist_boost = evidence_score
1297
+
1298
+ # ── Unsourced claim / blind item detection ──
1299
+ unsourced_score, unsourced_signals, attribution_count = _detect_unsourced_claim(
1300
+ text_lower
1301
+ )
1302
+ unsourced_flag = unsourced_score >= 4 # threshold: 2+ signals with few attributions
1303
+ if unsourced_flag:
1304
+ sensationalism_score += 2 # unsourced claims are a sensationalism signal
1305
+
1306
+ # ── Obfuscation / intentional misspell detection ──
1307
+ obfuscation_score, obfuscation_signals = _detect_obfuscation(text)
1308
+ obfuscated_flag = obfuscation_score >= 2
1309
+ if obfuscated_flag:
1310
+ sensationalism_score += min(3, obfuscation_score) # boost but cap contribution
1311
+
1312
+ # ── Subjectivity ──
1313
+ subjectivity = _compute_subjectivity(text)
1314
+ subjectivity_flag = subjectivity > SUBJECTIVITY_THRESHOLD
1315
+
1316
+ # ── VADER sentiment ──
1317
+ vader_scores = _compute_vader_sentiment(text)
1318
+ vader_compound = vader_scores["compound"]
1319
+ VADER_BIAS_THRESHOLD = 0.5
1320
+ vader_biased = abs(vader_compound) >= VADER_BIAS_THRESHOLD
1321
+ if vader_biased:
1322
+ sensationalism_score += 3
1323
+
1324
+ # ── Pattern deviation boost (primary signal when available) ────────────────
1325
+ # Deviation from reputable external sources is treated as a strong bias
1326
+ # indicator: articles that add claims not found in any credible source are
1327
+ # more likely to be biased or sensationalized, regardless of keyword matches.
1328
+ pd_verdict = (pattern_deviation or {}).get("verdict", "")
1329
+ pd_score = (pattern_deviation or {}).get("deviation_score", 0.0)
1330
+ pd_confidence_boost = 0.0
1331
+
1332
+ if pd_verdict == "SIGNIFICANT DEVIATION":
1333
+ sensationalism_score += 3 # strong signal: adds claims no credible source reports
1334
+ pd_confidence_boost = 0.08 # more certain about the detected leaning
1335
+ elif pd_verdict == "MINOR DEVIATION":
1336
+ sensationalism_score += 1 # mild signal
1337
+
1338
+ # ── Determine leaning ──
1339
+ total_political = right_score + left_score
1340
+ bias_threshold = 3 # raised slightly since we now have more signal sources
1341
+
1342
+ # Centrist boost: strong evidence markers counteract weak political scores
1343
+ effective_political = max(0, total_political - centrist_boost)
1344
+
1345
+ if effective_political < bias_threshold and sensationalism_score < 4:
1346
+ leaning = "Centrist"
1347
+ confidence = 0.6 + min(0.3, (bias_threshold - effective_political) * 0.1)
1348
+ elif right_score > left_score:
1349
+ # Distinguish Conservative (Duterte-camp) vs Admin/Right (Marcos-camp)
1350
+ if conservative_score >= admin_score:
1351
+ leaning = "Conservative"
1352
+ else:
1353
+ leaning = "Admin/Right"
1354
+ diff = right_score - left_score
1355
+ confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02 + pd_confidence_boost)
1356
+ elif left_score > right_score:
1357
+ leaning = "Left-leaning"
1358
+ diff = left_score - right_score
1359
+ confidence = min(0.95, 0.5 + diff * 0.08 + sensationalism_score * 0.02 + pd_confidence_boost)
1360
+ else:
1361
+ leaning = "Centrist"
1362
+ confidence = 0.45 if sensationalism_score >= 4 else 0.7
1363
+
1364
+ is_biased = (
1365
+ (leaning != "Centrist")
1366
+ or subjectivity_flag
1367
+ or sensationalism_score >= 4
1368
+ or vader_biased
1369
+ or unsourced_flag
1370
+ or obfuscated_flag
1371
+ )
1372
+
1373
+ return {
1374
+ "leaning": leaning,
1375
+ "is_biased": is_biased,
1376
+ "unsourced_claim": unsourced_flag,
1377
+ "unsourced_score": unsourced_score,
1378
+ "obfuscated_text": obfuscated_flag,
1379
+ "obfuscation_score": obfuscation_score,
1380
+ "subjectivity": round(subjectivity, 4),
1381
+ "subjectivity_flag": subjectivity_flag,
1382
+ "vader_compound": round(vader_compound, 4),
1383
+ "vader_biased": vader_biased,
1384
+ "confidence": round(min(confidence, 1.0), 4),
1385
+ "pattern_deviation_verdict": pd_verdict,
1386
+ "deviation_score": round(pd_score, 4),
1387
+ "details": {
1388
+ "left_keywords": left_hits,
1389
+ "right_keywords": right_hits,
1390
+ "sensational_keywords": sensational_hits,
1391
+ "opinion_markers": opinion_hits,
1392
+ "unsourced_signals": unsourced_signals,
1393
+ "attribution_count": attribution_count,
1394
+ "obfuscation_signals": obfuscation_signals,
1395
+ "figure_hits": figure_hits,
1396
+ "left_score": left_score,
1397
+ "right_score": right_score,
1398
+ "conservative_score": conservative_score,
1399
+ "admin_score": admin_score,
1400
+ "liberal_score": liberal_score,
1401
+ "evidence_score": evidence_score,
1402
+ "sensationalism_score": sensationalism_score,
1403
+ "pattern_deviation_verdict": pd_verdict,
1404
+ "deviation_score": round(pd_score, 4),
1405
+ },
1406
+ }