Spaces:
Sleeping
Sleeping
Abu-Sameer-66 commited on
Commit Β·
535dd76
0
Parent(s):
fix: v2.3.4 final version bump
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitattributes +1 -0
- .gitignore +0 -0
- Dockerfile +8 -0
- LICENSE +21 -0
- Procfile +1 -0
- README.md +11 -0
- data/scipeerai_bench/schema.py +154 -0
- data/scipeerai_bench/scipeerai_bench_borderline.csv +129 -0
- data/scipeerai_bench/scipeerai_bench_clean.csv +0 -0
- data/scipeerai_bench/scipeerai_bench_fraud.csv +0 -0
- data/scipeerai_bench/scipeerai_bench_sample.csv +13 -0
- data/scipeerai_bench/scipeerai_bench_v1.1.csv +0 -0
- data/scipeerai_bench/scipeerai_bench_v1.csv +0 -0
- main.py +7 -0
- models/model_config_honest.json +37 -0
- models/replication_predictor_honest.pkl +3 -0
- models/v2/discovery1_linguistic_divergence.csv +11 -0
- models/v2/discovery3_temporal_drift.csv +6 -0
- models/v2/discovery4_shap.csv +38 -0
- models/v2/discovery4_shap_direction.csv +38 -0
- models/v2/ensemble_config_v2.json +58 -0
- models/v2/feature_importance_v2.csv +38 -0
- models/v2/lgbm_v2.pkl +3 -0
- models/v2/lr_v2.pkl +3 -0
- models/v2/xgb_v2.pkl +3 -0
- railway.json +11 -0
- requirements.txt +19 -0
- src/scipeerai/__init__.py +0 -0
- src/scipeerai/api/__init__.py +35 -0
- src/scipeerai/api/routes.py +0 -0
- src/scipeerai/core/__init__.py +0 -0
- src/scipeerai/core/pdf_parser.py +215 -0
- src/scipeerai/modules/__init__.py +0 -0
- src/scipeerai/modules/ai_human_spectrum.py +556 -0
- src/scipeerai/modules/citation_analyzer.py +413 -0
- src/scipeerai/modules/citation_cartel.py +344 -0
- src/scipeerai/modules/citation_dna.py +516 -0
- src/scipeerai/modules/data_fingerprint.py +463 -0
- src/scipeerai/modules/effect_size_validator.py +329 -0
- src/scipeerai/modules/field_saturation.py +407 -0
- src/scipeerai/modules/figure_forensics.py +338 -0
- src/scipeerai/modules/fraud_fingerprint.py +524 -0
- src/scipeerai/modules/granularity_analyzer.py +369 -0
- src/scipeerai/modules/grim_test.py +167 -0
- src/scipeerai/modules/institutional_conflict.py +412 -0
- src/scipeerai/modules/llm_detector.py +335 -0
- src/scipeerai/modules/methodology_checker.py +549 -0
- src/scipeerai/modules/novelty_scorer.py +390 -0
- src/scipeerai/modules/pcurve_analyzer.py +228 -0
- src/scipeerai/modules/peer_review_score.py +522 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
Binary file (316 Bytes). View file
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ο»ΏFROM python:3.10-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 && rm -rf /var/lib/apt/lists/*
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY . .
|
| 7 |
+
EXPOSE 7860
|
| 8 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Abu-Sameer
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: uvicorn main:app --host 0.0.0.0 --port $PORT
|
README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ο»Ώ---
|
| 2 |
+
title: SciPeerAI API
|
| 3 |
+
emoji: π¬
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# SciPeerAI API
|
| 11 |
+
Automated Scientific Integrity Analysis β 14-Module Detection Pipeline
|
data/scipeerai_bench/schema.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SciPeerBench β Dataset schema definition.
|
| 3 |
+
|
| 4 |
+
World's first multi-dimensional scientific fraud benchmark.
|
| 5 |
+
Every paper labeled across 14 fraud dimensions simultaneously.
|
| 6 |
+
No other dataset does this.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ββ Fraud taxonomy β 20 types, first time formally defined βββββββββββββββββββ
|
| 14 |
+
|
| 15 |
+
FRAUD_TAXONOMY = {
|
| 16 |
+
# Data fabrication
|
| 17 |
+
"FAB-01": "Complete data fabrication",
|
| 18 |
+
"FAB-02": "Partial data fabrication",
|
| 19 |
+
"FAB-03": "Data duplication across papers",
|
| 20 |
+
|
| 21 |
+
# Statistical manipulation
|
| 22 |
+
"STAT-01": "P-hacking and selective reporting",
|
| 23 |
+
"STAT-02": "HARKing β hypothesizing after results known",
|
| 24 |
+
"STAT-03": "Impossible statistical values GRIM or SPRITE",
|
| 25 |
+
"STAT-04": "Inflated effect sizes",
|
| 26 |
+
"STAT-05": "Underpowered study with strong claims",
|
| 27 |
+
|
| 28 |
+
# Figure fraud
|
| 29 |
+
"FIG-01": "Duplicated figure panels",
|
| 30 |
+
"FIG-02": "Manipulated western blots",
|
| 31 |
+
"FIG-03": "Image brightness or contrast manipulation",
|
| 32 |
+
|
| 33 |
+
# Citation fraud
|
| 34 |
+
"CIT-01": "Excessive self-citation ring",
|
| 35 |
+
"CIT-02": "Citation cartel coordinated group",
|
| 36 |
+
"CIT-03": "Unsupported claims without citation",
|
| 37 |
+
|
| 38 |
+
# Methodology fraud
|
| 39 |
+
"METH-01": "Causation claimed without RCT",
|
| 40 |
+
"METH-02": "Missing control group",
|
| 41 |
+
"METH-03": "Undisclosed conflicts of interest",
|
| 42 |
+
|
| 43 |
+
# Authorship and integrity
|
| 44 |
+
"AUTH-01": "LLM-generated paper",
|
| 45 |
+
"AUTH-02": "Plagiarism detected",
|
| 46 |
+
"AUTH-03": "Retracted paper cited as valid",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ββ Paper categories for balanced dataset ββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
|
| 52 |
+
PAPER_CATEGORIES = {
|
| 53 |
+
"CONFIRMED_FRAUD": "Retracted with documented fraud reason",
|
| 54 |
+
"SUSPECTED_FRAUD": "PubPeer flagged, not retracted yet",
|
| 55 |
+
"BORDERLINE": "Minor issues, not clear fraud",
|
| 56 |
+
"CLEAN": "High quality, replicated, no concerns",
|
| 57 |
+
"BASELINE_ELITE": "Nobel prize or landmark papers",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ββ Source databases ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
|
| 63 |
+
DATA_SOURCES = {
|
| 64 |
+
"retraction_watch": "https://retractionwatch.com",
|
| 65 |
+
"pubpeer": "https://pubpeer.com",
|
| 66 |
+
"pubmed": "https://pubmed.ncbi.nlm.nih.gov",
|
| 67 |
+
"arxiv": "https://arxiv.org",
|
| 68 |
+
"semantic_scholar": "https://api.semanticscholar.org",
|
| 69 |
+
"crossref": "https://api.crossref.org",
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ββ Target distribution β 1000 papers total ββββββββββββββββββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
TARGET_DISTRIBUTION = {
|
| 76 |
+
"CONFIRMED_FRAUD": 300, # from RetractionWatch
|
| 77 |
+
"SUSPECTED_FRAUD": 200, # from PubPeer
|
| 78 |
+
"BORDERLINE": 150, # gray area
|
| 79 |
+
"CLEAN": 250, # normal good papers
|
| 80 |
+
"BASELINE_ELITE": 100, # Nobel / landmark
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class PaperRecord:
|
| 86 |
+
"""
|
| 87 |
+
One row in SciPeerBench.
|
| 88 |
+
Every paper labeled across all 14 fraud dimensions.
|
| 89 |
+
This is the most comprehensive fraud labeling schema ever built.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
# ββ Identity ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
paper_id: str # SPB-0001, SPB-0002 ...
|
| 94 |
+
doi: Optional[str]
|
| 95 |
+
title: str
|
| 96 |
+
authors: str # comma separated
|
| 97 |
+
year: int
|
| 98 |
+
journal: str
|
| 99 |
+
source_db: str # where we got it
|
| 100 |
+
|
| 101 |
+
# ββ Ground truth ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
category: str # from PAPER_CATEGORIES
|
| 103 |
+
is_fraud: int # 1 = fraud, 0 = clean
|
| 104 |
+
fraud_confidence: float # 0.0 to 1.0
|
| 105 |
+
fraud_types: str # comma separated FRAUD_TAXONOMY keys
|
| 106 |
+
retraction_date: Optional[str] # YYYY-MM-DD
|
| 107 |
+
retraction_reason: Optional[str]
|
| 108 |
+
pubpeer_url: Optional[str]
|
| 109 |
+
|
| 110 |
+
# ββ 14 module scores β auto-generated by running our system βββ
|
| 111 |
+
stat_audit_score: float = 0.0
|
| 112 |
+
figure_forensics_score: float = 0.0
|
| 113 |
+
methodology_score: float = 0.0
|
| 114 |
+
citation_score: float = 0.0
|
| 115 |
+
reproducibility_score: float = 0.0
|
| 116 |
+
novelty_score: float = 0.0
|
| 117 |
+
grim_score: float = 0.0
|
| 118 |
+
sprite_score: float = 0.0
|
| 119 |
+
granularity_score: float = 0.0
|
| 120 |
+
pcurve_score: float = 0.0
|
| 121 |
+
effect_size_score: float = 0.0
|
| 122 |
+
retraction_score: float = 0.0
|
| 123 |
+
cartel_score: float = 0.0
|
| 124 |
+
llm_score: float = 0.0
|
| 125 |
+
|
| 126 |
+
# ββ Weighted average of all 14 scores βββββββββββββββββββββββββ
|
| 127 |
+
overall_risk_score: float = 0.0
|
| 128 |
+
|
| 129 |
+
# ββ Paper content βββββββββββββββββββββββββββββββββββββββββββββ
|
| 130 |
+
abstract_text: str = ""
|
| 131 |
+
full_text_path: str = "" # path to saved full text
|
| 132 |
+
|
| 133 |
+
# ββ Metadata ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
field_of_study: str = "" # biology, psychology, medicine...
|
| 135 |
+
labeling_method: str = "" # auto, manual, auto+manual
|
| 136 |
+
labeled_by: str = "auto"
|
| 137 |
+
notes: str = ""
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ββ CSV column order β exact order in output file ββββββββββββββββββββββββββββ
|
| 141 |
+
|
| 142 |
+
CSV_COLUMNS = [
|
| 143 |
+
"paper_id", "doi", "title", "authors", "year",
|
| 144 |
+
"journal", "source_db",
|
| 145 |
+
"category", "is_fraud", "fraud_confidence",
|
| 146 |
+
"fraud_types", "retraction_date", "retraction_reason", "pubpeer_url",
|
| 147 |
+
"stat_audit_score", "figure_forensics_score", "methodology_score",
|
| 148 |
+
"citation_score", "reproducibility_score", "novelty_score",
|
| 149 |
+
"grim_score", "sprite_score", "granularity_score", "pcurve_score",
|
| 150 |
+
"effect_size_score", "retraction_score", "cartel_score", "llm_score",
|
| 151 |
+
"overall_risk_score",
|
| 152 |
+
"abstract_text", "full_text_path",
|
| 153 |
+
"field_of_study", "labeling_method", "labeled_by", "notes",
|
| 154 |
+
]
|
data/scipeerai_bench/scipeerai_bench_borderline.csv
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paper_id,doi,title,authors,year,journal,source_db,category,is_fraud,fraud_confidence,fraud_types,retraction_date,retraction_reason,pubpeer_url,stat_audit_score,figure_forensics_score,methodology_score,citation_score,reproducibility_score,novelty_score,grim_score,sprite_score,granularity_score,pcurve_score,effect_size_score,retraction_score,cartel_score,llm_score,overall_risk_score,abstract_text,full_text_path,field_of_study,labeling_method,labeled_by,notes
|
| 2 |
+
SPB-0525,10.1159/000548698,Expression of Concern.,Unknown,2025,Neuroimmunomodulation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41363674 β expression of concern
|
| 3 |
+
SPB-0526,10.1159/000549719,Expression of Concern.,Unknown,2026,Chemotherapy,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41563927 β expression of concern
|
| 4 |
+
SPB-0527,10.1159/000548461,Expression of Concern.,Unknown,2026,International archives of allergy and immunology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41231741 β expression of concern
|
| 5 |
+
SPB-0528,10.1080/19336950.2025.2570092,Expression of Concern.,Unknown,2025,"Channels (Austin, Tex.)",pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41065763 β expression of concern
|
| 6 |
+
SPB-0529,10.1080/14712598.2025.2596474,Expression of Concern.,Unknown,2025,Expert opinion on biological therapy,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41404834 β expression of concern
|
| 7 |
+
SPB-0530,10.1016/j.biopsych.2025.09.009,Expression of Concern.,Unknown,2025,Biological psychiatry,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41238290 β expression of concern
|
| 8 |
+
SPB-0531,10.1177/09287329251392360,Expression of concern.,Unknown,2025,Technology and health care : official journal of the European Society for Engineering and Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41223024 β expression of concern
|
| 9 |
+
SPB-0532,10.1177/10445498261435664,Expression of Concern.,Unknown,2026,DNA and cell biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41882964 β expression of concern
|
| 10 |
+
SPB-0533,10.1002/ijgo.70756,Expression of Concern.,Unknown,2026,International journal of gynaecology and obstetrics: the official organ of the International Federation of Gynaecology and Obstetrics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41437141 β expression of concern
|
| 11 |
+
SPB-0534,10.1302/2046-3758.151.BJR-2026-00002,Expression of Concern.,"Zhang Hongbo, Li Ziyang, Zhang Zhicheng, Li Haobin, Yao Zihao, et al.",2026,Bone & joint research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41575261 β expression of concern
|
| 12 |
+
SPB-0535,10.26355/eurrev_202512_37562,"Expression of Concern on ""Effects of inositol on ovarian function and metabolic factors in women with PCOS: a randomized double blind placebo-controlled trial"".",Unknown,2025,European review for medical and pharmacological sciences,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"The Editor in Chief and the Publisher are issuing an expression of concern regarding the following article: Gerli S, Mignosa M, Di Renzo GC. Effects of inositol on ovarian function and metabolic factors in women with PCOS: a randomized double blind placebo-controlled trial. Eur Rev Med Pharmacol Sci 2003; 7(6): 151-159-PMID: 15206484. This Expression of Concern is issued following a whistleblower's report regarding errors in the study protocol and results, as well as ethical concerns. The authors have informed the journal that the original data are no longer available due to the 22 years that have elapsed since the article's publication.Β In the absence of these data, we are issuing this Expression of Concern to alert readers that concerns have been raised about this article. The Publisher apologizes for any inconvenience this may cause. https://www.europeanreview.org/article/94.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41489236 β expression of concern
|
| 13 |
+
SPB-0536,10.1172/JCI200302,Expression of Concern for JAK2-binding long noncoding RNA promotes breast cancer brain metastasis.,"Wang Shouyu, Liang Ke, Hu Qingsong, Li Ping, Song Jian, et al.",2025,The Journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41031879 β expression of concern
|
| 14 |
+
SPB-0537,10.1126/sciimmunol.aed4813,Editorial expression of concern.,Scanlon Seth Thomas,2025,Science immunology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41270191 β expression of concern
|
| 15 |
+
SPB-0538,10.1126/sciadv.aec3110,Editorial expression of concern.,Thorp H Holden,2025,Science advances,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41032620 β expression of concern
|
| 16 |
+
SPB-0539,10.1038/s41586-025-09942-8,Editorial Expression of Concern: Cytokinesis failure generating tetraploids promotes tumorigenesis in p53-null cells.,"Fujiwara Takeshi, Bandi Madhavi, Nitta Masayuki, Ivanova Elena V, Bronson Roderick T, et al.",2025,Nature,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41326737 β expression of concern
|
| 17 |
+
SPB-0540,10.1111/exd.70170,EXPRESSION OF CONCERN: Dermal Papilla Cell-Secreted Biglycan Regulates Hair Follicle Phase Transit and Regeneration by Activating Wnt/Ξ²-Catenin.,Unknown,2025,Experimental dermatology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"N. Zhu, J. Yan, W. Gu, Q. Yang, E. Lin, S. Lu, B. Cai, B. Xia, X. Liu, C. Lin, ""Dermal Papilla Cell-Secreted Biglycan Regulates Hair Follicle Phase Transit and Regeneration by Activating Wnt/Ξ²-Catenin,"" Experimental Dermatology 33, no. 1 (2024): e14969, https://doi.org/10.1111/exd.14969. This Expression of Concern is for the above article, published online on 15 November 2023 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Akimichi Morita, and John Wiley & Sons Ltd. The Expression of Concern has been agreed upon following the identification of duplicated GAPDH bands in FiguresΒ 1C and 2C. Additional concerns were raised about the authenticity of bands in FiguresΒ 2C and 3C, which lacked characteristics typically associated with original experimental data. Although the authors cooperated and submitted data, the materials provided did not meet the standards for raw data and were insufficient to validate the figures. Th",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41131858 β expression of concern
|
| 18 |
+
SPB-0541,10.1097/MD.0000000000046330,Expression of Concern: Study Protocols.,Unknown,2025,Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41204616 β expression of concern
|
| 19 |
+
SPB-0542,10.1038/s41591-025-04116-5,Editorial Expression of Concern: Rationale for co-targeting IGF-1R and ALK in ALK fusion-positive lung cancer.,"Lovly Christine M, McDonald Nerina T, Chen Heidi, Ortiz-Cuaran Sandra, Heukamp Lukas C, et al.",2025,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41266687 β expression of concern
|
| 20 |
+
SPB-0543,10.1038/s41594-025-01730-2,Editorial Expression of Concern: Munc13 C<sub>2</sub>B domain is an activity-dependent Ca<sup>2+</sup> regulator of synaptic exocytosis.,"Shin Ok-Ho, Lu Jun, Rhee Jeong-Seop, Tomchick Diana R, Pang Zhiping P, et al.",2025,Nature structural & molecular biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41339862 β expression of concern
|
| 21 |
+
SPB-0544,10.1177/08977151261430329,Expression of Concern.,Unknown,2026,Journal of neurotrauma,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41882968 β expression of concern
|
| 22 |
+
SPB-0545,10.1111/eci.70186,EXPRESSION OF CONCERN: COVID-19 re-infection.,Unknown,2026,European journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41738956 β expression of concern
|
| 23 |
+
SPB-0546,10.1016/j.heliyon.2025.e44226,"Expression of concern: ""SQSTM1/p62 promotes the progression of gastric cancer through epithelial-mesenchymal transition"" [Heliyon 10 (2024) e24409].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497880 β expression of concern
|
| 24 |
+
SPB-0547,10.1172/JCI202595,Expression of Concern for Axon guidance cue SLIT2 regulates the murine skeletal stem cell niche through sympathetic innervation.,"Wu Zuoxing, Li Na, Luo Zhengqiong, Chen Zihan, He Xuemei, et al.",2025,The Journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41392979 β expression of concern
|
| 25 |
+
SPB-0548,10.1016/j.biopha.2025.118302,Expression of Concern - Organ provenance case.,Unknown,2025,Biomedicine & pharmacotherapy = Biomedecine & pharmacotherapie,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41233974 β expression of concern
|
| 26 |
+
SPB-0549,10.1038/s41564-025-02195-1,Editorial Expression of Concern: Metabolic remodelling produces fumarate via the aspartate-argininosuccinate shunt in macrophages as an antiviral defence.,"Xia Wenjun, Mao Youxiang, Xia Ziyan, Cheng Jie, Jiang Peng",2025,Nature microbiology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41188385 β expression of concern
|
| 27 |
+
SPB-0550,10.1038/s41586-026-10247-7,Editorial Expression of Concern: Transcription-independent ARF regulation in oncogenic stress-mediated p53 responses.,"Chen Delin, Shan Jing, Zhu Wei-Guo, Qin Jun, Gu Wei",2026,Nature,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41699183 β expression of concern
|
| 28 |
+
SPB-0551,10.1186/s12933-025-03045-4,Editorial Expression of Concern: High triglycerideβglucose index and stress hyperglycemia ratio as predictors of adverse cardiac events in patients with coronary chronic total occlusion: a largeβscale prospective cohort study.,"Song Yanjun, Cui Kongyong, Yang Min, Song Chenxi, Yin Dong, et al.",2025,Cardiovascular diabetology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41437363 β expression of concern
|
| 29 |
+
SPB-0552,10.1038/s41591-025-04149-w,Editorial Expression of Concern: Amplification of LAPTM4B and YWHAZ contributes to chemotherapy resistance and recurrence of breast cancer.,"Li Yang, Zou Lihua, Li Qiyuan, Haibe-Kains Benjamin, Tian Ruiyang, et al.",2025,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41350453 β expression of concern
|
| 30 |
+
SPB-0553,10.3892/ijo.2025.5829,[Expression of Concern] MUC1 is a downstream target of STAT3 and regulates lung cancer cell survival and invasion.,"Gao Jingchun, Mcconnell Matthew J, Yu Bin, Li Jiannong, Balko Justin M, et al.",2026,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that the Bclβ2 protein bands shown for the HCC827 cell line in the western blots in Fig. 3A on p. 342 were unexpectedly similar to the Akt protein bands shown for the A549 cell line in Fig. 3B. In addition, the Bclβ2 protein bands shown for the H358 and HCC827 cell lines (the leftβhand and middle gels) possibly contained a pair of mutually overlapping protein bands, and the Ξ²βactin control bands featured in the leftβhand and middle lanes of Fig. 3A for the H358 and HCC827 cell lines, and the Ξ²βactin control bands featured in the middle and rightβhand lanes for the H358 and HCC827 cell lines in Fig. 1C on p. 340, were similarly more similar than might have been expected. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcomi",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41347839 β expression of concern
|
| 31 |
+
SPB-0554,10.1038/s41591-026-04295-9,Editorial Expression of Concern: Rescue of Hippo coactivator YAP1 triggers DNA damage-induced apoptosis in hematological cancers.,"Cottini Francesca, Hideshima Teru, Xu Chunxiao, Sattler Martin, Dori Martina, et al.",2026,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41741727 β expression of concern
|
| 32 |
+
SPB-0555,10.1136/bmj.r2395,EXPRESSION OF CONCERN: Participation in early mammography screening.,Unknown,2025,BMJ (Clinical research ed.),pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41290351 β expression of concern
|
| 33 |
+
SPB-0556,10.1038/s41591-026-04242-8,Editorial Expression of Concern: Direct targeting of Sec23a by miR-200s influences cancer cell secretome and promotes metastatic colonization.,"Korpal Manav, Ell Brian J, Buffa Francesca M, Ibrahim Toni, Blanco Mario A, et al.",2026,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41639381 β expression of concern
|
| 34 |
+
SPB-0557,10.1002/trc2.70224,EXPRESSION OF CONCERN: Focused Transcranial Ultrasound for Treatment of Neurodegenerative Dementia.,Unknown,2026,"Alzheimer's & dementia (New York, N. Y.)",pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"N. E. Nicodemus, S. Becerra, T. P. Kuhn, H. R. Packham, J. Duncan, K. Mahdavi, J. Iovine, S. Kesari, S. Pereles, M. Whitney, M. Mamoun, D. Franc, A. Bystritsky, and S. Jordan, ""Focused Transcranial Ultrasound for Treatment of Neurodegenerative Dementia,"" Alzheimer's & Dementia: Translational Research & Clinical InterventionsΒ 5, no. 1 (2019): 374-381, https://doi.org/10.1016/j.trci.2019.06.007. This Expression of Concern is for the above article, published online on 9 August 2019 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Barry D. Greenberg; theΒ Alzheimer's Association; and Wiley Periodicals LLC. The Expression of Concern has been agreed due to concerns raised by third parties. Specifically, contradictory and/or unsupported statements have been identified in the article. In addition, there are concerns regarding potentially undisclosed conflicts of interest for one or more of the co-authors, related to possible",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41768474 β expression of concern
|
| 35 |
+
SPB-0558,10.1097/AOG.0000000000006180,Expression of Concern: Fezolinetant and Elinzanetant Therapy for Menopausal Women Experiencing Vasomotor Symptoms: A Systematic Review and Meta-Analysis.,Unknown,2026,Obstetrics and gynecology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41538797 β expression of concern
|
| 36 |
+
SPB-0559,10.2807/1560-7917.ES.2020.31.3.260122ec,Expression of concern for Euro Surveill. 2026;31(2).,Unknown,2026,Euro surveillance : bulletin Europeen sur les maladies transmissibles = European communicable disease bulletin,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41546487 β expression of concern
|
| 37 |
+
SPB-0560,10.1161/JAHA.125.045055,Expression of Concern:βEpothilone B Benefits Nigrostriatal Pathway Recovery by Promoting Microtubule Stabilization After Intracerebral Hemorrhage.,Unknown,2025,Journal of the American Heart Association,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"The Editors of Journal of the American Heart Association (JAHA) are issuing an Expression of Concern regarding tge article, ""Epothilone B Benefits Nigrostriatal Pathway Recovery by Promoting Microtubule Stabilization After Intracerebral Hemorrhage"" (JAHA. 2018. DOI: https://doi.org/10.1161/JAHA.117.007626). Concerns have been raised regarding the quality of data and images in the aforementioned article. The editors and Scientific Publishing Committee are in communication with the authors regarding the integrity of the image data, and Loma Linda University has confirmed the concerns are being reviewed. While we await the outcome of this review, we are publishing this Expression of Concern to indicate that the data and statements in the article may not be reliable.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41085180 β expression of concern
|
| 38 |
+
SPB-0561,10.3892/ijo.2025.5808,[Expression of Concern] miRβ221/222 promote malignant progression of glioma through activation of the Akt pathway.,"Zhang Junxia, Han Lei, Ge Youlin, Zhou Xuan, Zhang Anling, et al.",2025,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, a concerned reader drew to the Editor's attention that, for the immuno-histochemistry images shown in Fig. 6, the Control/PCNA and Control/p27kip1 panels appeared to be duplicates of each other, where the results of differently performed experiments were intended to have been portrayed. The authors were contacted by the Editorial Office to offer an explanation for this potential anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [International Journal of Oncology 36: 913β920, 2010; DOI: 10.3892/ijo_00000570].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41070621 β expression of concern
|
| 39 |
+
SPB-0562,10.3892/or.2025.8989,[Expression of Concern] Hypoxia and macrophages promote glioblastoma invasion by the CCL4βCCR5 axis.,"Wang Ying, Liu Tao, Yang Ning, Xu Shuo, Li Xingang, et al.",2025,Oncology reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that the 'Normoxia / U87' panel in Fig. 1A appeared to overlap with the 'CCR5 siRNA' panel in Fig. 2B; in addition, the 'Hypoxia / U87βMΟ' panel in Fig. 1A appeared to overlap with the 'Control siRNA' panel in Fig. 2B. The authors were contacted by the Editorial Office to offer an explanation for these apparent anomalies in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Oncology Reports 36: 3522β3528, 2016; DOI: 10.3892/or.2016.5171].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40970355 β expression of concern
|
| 40 |
+
SPB-0563,10.1111/1755-0998.70061,EXPRESSION OF CONCERN: A Novel Technique for Estimating Age and Demography of Long-Lived Seabirds (Genus Pterodroma) Using an Epigenetic Clock for Gould's Petrel (Pterodroma leucoptera).,Unknown,2025,Molecular ecology resources,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"L. Roman, B. Mayne, C. Anderson, Y. Kim, T. O'Dwyer, and N. Carlile, ""A Novel Technique for Estimating Age and Demography of Long-Lived Seabirds (Genus Pterodroma) Using an Epigenetic Clock for Gould's Petrel (Pterodroma leucoptera),"" Molecular Ecology Resources 24, no. 7 (2024): e14003, https://doi.org/10.1111/1755-0998.14003. This Expression of Concern is for the above article, published online on 29 July 2024 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Ben Sibbett; and John Wiley & Sons Ltd. The Expression of Concern has been agreed upon following concerns raised by one of the authors, L. Roman, regarding potential inaccuracies in the data presented in the article. These concerns suggest the study may not be reproducible. The institution is currently investigating the matter and has requested that the Expression of Concern be published during the course of the investigation.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41099181 β expression of concern
|
| 41 |
+
SPB-0564,10.1093/lambio/ovaf139,Expression of concern: Enhancement of microbial growth in Pseudomonas species through mutagenesis.,Unknown,2025,Letters in applied microbiology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41399940 β expression of concern
|
| 42 |
+
SPB-0565,10.1038/s41590-025-02379-1,"Editorial Expression of Concern: HoxC4 binds to the promoter of the cytidine deaminase AID gene to induce AID expression, class-switch DNA recombination and somatic hypermutation.","Park Seok-Rae, Zan Hong, Pal Zsuzsanna, Zhang Jinsong, Al-Qahtani Ahmed, et al.",2026,Nature immunology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41388154 β expression of concern
|
| 43 |
+
SPB-0566,10.3892/mmr.2025.13688,[Expression of Concern] PirB restricts neuronal regeneration in developing rat brain following hypoxiaβischemia.,"Wang Hua, Xiong Ying, Mu Dezhi",2025,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, regarding the immunofluorescence images shown in Fig. 3, the 'HIBD' panel appeared to share an overlapping section of data with the adjoining 'HIBD+anti PirB' panel, albeit the data held in common appeared to have been vertically stretched in the latter panel. The authors were contacted by the Editorial Office to offer an explanation for this apparent anomaly in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 6: 339β344, 2012; DOI: 10.3892/mmr.2012.907].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40970342 β expression of concern
|
| 44 |
+
SPB-0567,10.1161/JAHA.125.044453,Expression of Concern: Netrin-1 Preserves Blood-Brain Barrier Integrity Through Deleted in Colorectal Cancer/Focal Adhesion Kinase/RhoA Signaling Pathway Following Subarachnoid Hemorrhage inΒ Rats.,Unknown,2025,Journal of the American Heart Association,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"The Editors of the Journal of the American Heart Association (JAHA) are issuing an Expression of Concern regarding the article, ""Netrin-1 Preserves Blood-Brain Barrier Integrity Through Deleted in Colorectal Cancer/Focal Adhesion Kinase/RhoA Signaling Pathway Following Subarachnoid Hemorrhage in Rats"" (JAHA. 2017. DOI: https://doi.org/10.1161/JAHA.116.005198). Concerns have been raised regarding the quality of data and images in the aforementioned article. The editors and Scientific Publishing Committee are in communication with the authors regarding the integrity of the image data, and Loma Linda University has confirmed the concerns are being reviewed. While we await the outcome of this review, we are publishing this Expression of Concern to indicate that the data and statements in the article may not be reliable.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41085211 β expression of concern
|
| 45 |
+
SPB-0568,10.3892/mmr.2026.13843,[Expression of Concern] Dexmedetomidine protects against sepsisβassociated encephalopathy through Hsp90/AKT signaling.,"Yin Lijun, Chen Xuejun, Ji Hongbo, Gao Shunli",2026,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that western blot data featured in Fig. 4A were strikingly similar to data that appeared subsequently in the journal Journal of Clinical Science that was written by different authors. Additionally, the rightβhand Ξ²βactin band in Fig. 1A was apparently the same as the leftβhand Ξ²βactin band in Fig. 1B; the rightβhand band in the gel for cleaved caspaseβ3 in Fig. 5A was apparently the same as the leftβhand protein band for the Bclβ2 blots in Fig. 5B; and the Ξ²βactin bands in Fig. 4A seemed to match with most of the Ξ²βactin bands in Fig. 1A.Β The authors have been contacted by the Editorial Office to offer an explanation for these apparent anomalies in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of thi",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41823530 β expression of concern
|
| 46 |
+
SPB-0569,10.1002/1873-3468.70221,EXPRESSION OF CONCERN: Mitogen-Activated Protein Kinase Phosphatase-1 (MKP-1): >100-Fold Nocturnal and Norepinephrine-Induced Changes in the Rat Pineal Gland.,Unknown,2025,FEBS letters,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Expression of Concern: D.M. Price, C.L. Chik, D. Terriff, J. Weller, A. Humphries, D.A. Carter, D.C. Klein, and A.K. Ho, ""Mitogen-Activated Protein Kinase Phosphatase-1 (MKP-1): >100-Fold Nocturnal and Norepinephrine-Induced Changes in the Rat Pineal Gland,"" FEBS Letters 577, no. 1-2 (2004): 220-226, https://doi.org/10.1016/j.febslet.2004.09.083. This Expression of Concern is for the above article, published online on 18 October 2004 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Michael Brunner; Federation of European Biochemical Societies (FEBS); and John Wiley & Sons Ltd. A third party alerted the journal to possible duplications of the GADPH loading control bands in Figures 1B, 2B, and 3B. The journal verified the duplications and the publisher attempted to contact the authors for original data and comments. The authors did not respond to the publisher's requests. In the absence of the original data, the journ",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41223011 β expression of concern
|
| 47 |
+
SPB-0570,10.1177/03000605251408199,"Expression of concern: ""Perioperative risk factors for pulmonary complications after liver transplantation"".",Unknown,2025,The Journal of international medical research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41456180 β expression of concern
|
| 48 |
+
SPB-0571,10.1111/acer.70270,"EXPRESSION OF CONCERN: Tattoos, Piercings, and Alcohol Consumption.",Unknown,2026,"Alcohol, clinical & experimental research",pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41879256 β expression of concern
|
| 49 |
+
SPB-0572,10.1002/jcb.70070,"EXPRESSION OF CONCERN: Kaempferol Induces Apoptosis in Two Different Cell Lines via Akt Inactivation, Bax and SIRT3 Activation, and Mitochondrial Dysfunctionβ .",Unknown,2025,Journal of cellular biochemistry,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"G. Marfe, M. Tafani, M. Indelicato, P. Sinibaldi-Salimei, V. Reali, B. Pucci, M. Fini and M. A. Russo ""Kaempferol Induces Apoptosis in Two Different Cell Lines via Akt Inactivation, Bax and SIRT3 Activation, and Mitochondrial Dysfunctionβ ,"" Journal of Cellular Biochemistry 106, no. 4 (2009): 643-650, https://doi.org/10.1002/jcb.22044. This Expression of Concern is for the above article, published online on 21 January 2009 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Christian Behl; and Wiley Periodicals LLC. The Expression of Concern has been issued due to the identification of duplicated Ξ²-actin bands for the K562 and U937 cell lines in Figure 6βA. The authors agree that the bands appear very similar and believe this resulted from figure mismanagement. Due to the time that has elapsed since the study was conducted, the authors were unable to produce the original blots. The editors are satisfied that the article",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41217004 β expression of concern
|
| 50 |
+
SPB-0573,10.1016/j.molpha.2026.100106,"Expressions of Concern, Corrigenda, and a Retraction.","Greenwood-Van Meerveld Beverley, Tesmer John, Jarvis Michael",2027,Molecular pharmacology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41935897 β expression of concern
|
| 51 |
+
SPB-0574,10.1016/j.jpet.2026.103834,"Expressions of Concern, Corrigenda, and a Retraction.","Greenwood-Van Meerveld Beverley, Tesmer John J G, Jarvis Michael",2027,The Journal of pharmacology and experimental therapeutics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41935933 β expression of concern
|
| 52 |
+
SPB-0575,10.1371/journal.pone.0338753,Expression of Concern: Governance and competitiveness evaluation of China's financial asset management corporations.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41396866 β expression of concern
|
| 53 |
+
SPB-0576,10.14814/phy2.70689,EXPRESSION OF CONCERN: Cardiomyopathy Characterizing and Heart Failure Risk Predicting by Echocardiography and Pathoanatomy in Aged Male Mice.,Unknown,2025,Physiological reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"X.-J. Du, X.-H. Feng, Z.-Q. Ming, and H. Kiriazis, ""Cardiomyopathy Characterizing and Heart Failure Risk Predicting by Echocardiography and Pathoanatomy in Aged Male Mice,"" Physiological Reports 12, no. 20 (2024): e70061. https://doi.org/10.14814/phy2.70061. This Expression of Concern is for the above article, published online on October 16, 2024 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Josephine C. Adams; The American Physiological Society; The Physiological Society; and Wiley Periodicals LLC. The Expression of Concern has been agreed upon after it was determined that the TG panel in figureΒ 3a is duplicated from an article published in another journal 14βyears earlier by an author common to both publications. The authors cooperated with the investigation and explained that the duplication was an inadvertent error; they also provided supporting data. However, although the conclusions are believed to remain u",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41335528 β expression of concern
|
| 54 |
+
SPB-0577,10.3892/ijo.2025.5814,[Expression of Concern] Regulation of NADPH oxidase (Nox2) by lipid rafts in breast carcinoma cells.,"Malla Rama Rao, Raghu Hari, Rao Jasti S",2026,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, a potential problem regarding the presentation of the coβlocalization experiments shown in Fig. 5A and C was brought to the Editor's attention by a concerned reader. Specifically, the Flotillin/gp91 coβlocalization panels (Fig. 5A) appeared to be unexpectedly similar to the Flotillin/p22 panels (Fig. 5C), even though, according to the Materials and methods section, the different antibody treatments that were reported might have precluded the possibility of these images looking so similar. The authors were contacted by the Editorial Office to offer an explanation for this potential anomaly in the presentation of the data in this paper (or to clarify how the experiments had been performed), although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify ",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41133462 β expression of concern
|
| 55 |
+
SPB-0578,10.1111/jpi.70126,EXPRESSION OF CONCERN: Melatonin Protects Against Endometriosis via Regulation of Matrix Metalloproteinase-3 and an Apoptotic Pathway.,Unknown,2026,Journal of pineal research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"S. Paul, P. Bhattacharya, P. D. Mahapatra, and S. Swarnakar, ""Melatonin Protects Against Endometriosis via Regulation of Matrix Metalloproteinase-3 and an Apoptotic Pathway,"" Journal of Pineal Research 49, no. 2 (2010): 156-168, https://doi.org/10.1111/j.1600-079X.2010.00780.x. This Expression of Concern is for the above article, published online on 02 August 2010 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Gianluca Tosini; and John Wiley & Sons Ltd. The Expression of Concern has been agreed due to concerns raised by third parties. Specifically, the features of the sections presented in Figureβ―6 (A), (B), and (C) were found to closely resemble those of small intestine rather than endometrial (uterine) tissue in mice. Due to the time elapsed since publication, the authors were unable to retrieve the raw data underlying the images. Despite the authors highlighting several features in their clarification to suppor",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41769762 β expression of concern
|
| 56 |
+
SPB-0579,10.1016/j.heliyon.2025.e44222,"Expression of concern: ""On stratified single-valued soft topogenous structures"" [Heliyon 10 (2024) e27926].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497877 β expression of concern
|
| 57 |
+
SPB-0580,10.3892/ijo.2025.5818,[Expression of Concern] Radiosensitization of esophageal carcinoma cells by knockdown of RNF2 expression.,"Yang Xing-Xiao, Ma Ming, Sang Mei-Xiang, Wang Xue-Xiao, Song Heng, et al.",2026,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that certain of the westen blot data included in Fig. 4C and D looked strikingly similar, such that the same data may have been included more than once in these figure parts to show the results of differently performed experiments. Upon performing an independent analysis of the data in the Editorial Office, it also came to light that data featured in Fig. 3A of the above paper had been reβused in a figure in a paper featuring some of the same authors that was published in the journal Scientific Reports. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression o",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41235657 β expression of concern
|
| 58 |
+
SPB-0581,10.1002/ijgo.70659,EXPRESSION OF CONCERN: Addition of Growth Hormone to the Microflare Stimulation Protocol among Women with Poor Ovarian Response.,Unknown,2026,International journal of gynaecology and obstetrics: the official organ of the International Federation of Gynaecology and Obstetrics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Y. A. Bayoumi, D. M. R. Dakhly, Y. A. Bassiouny, N. M. Hashish, ""Addition of Growth Hormone to the Microflare Stimulation Protocol among Women with Poor Ovarian Response,"" International Journal of Gynecology & Obstetrics 131, no. 3 (2015): 305-308, https://doi.org/10.1016/j.ijgo.2015.05.034. This Expression of Concern is for the above article, published online on 23 August 2015, in Wiley Online Library (http://onlinelibrary.wiley.com/), and has been issued by agreement between the journal Editor-in-Chief, Michael Geary; the International Federation of Gynecology and Obstetrics; and John Wiley & Sons Ltd. Multiple third parties have reported concerns regarding the feasibility of the study reported and the validity of the data. The journal contacted the authors, who provided a response to these concerns as well as raw data for the study. Multiple expert reviewers have investigated these data. While concerns were raised about the logical feasibility of the study by some reviewers, the rev",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41236126 β expression of concern
|
| 59 |
+
SPB-0582,10.1177/03000605251408201,"Expression of concern: ""New endoscopic classification system for biliary stricture after liver transplantation"".",Unknown,2025,The Journal of international medical research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41456182 β expression of concern
|
| 60 |
+
SPB-0583,10.1016/j.semcancer.2025.11.015,"Expression of concern ""Fungi, immunosenescence and cancer"" [Semin. Cancer Biol. 109 (2025) 67-82].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365568 β expression of concern
|
| 61 |
+
SPB-0584,10.1161/STR.0000000000000497,"Expression of Concern for: ""Isoflurane Post-Treatment Ameliorates GMH-Induced Brain Injury in Neonatal Rats"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064850 β expression of concern
|
| 62 |
+
SPB-0585,10.3892/or.2025.9004,[Expression of Concern] Combination of the FGFR4 inhibitor PD173074 and 5βfluorouracil reduces proliferation and promotes apoptosis in gastric cancer.,"Ye Yan-Wei, Hu Shuang, Shi Ying-Qiang, Zhang Xie-Fu, Zhou Ye, et al.",2025,Oncology reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, for the western blot data shown in Figs. 1E and 4, the FGFR blots and control GAPDH blots were both strikingly similar in these figures, suggesting that the same data had been included in these, even though the results from differently performed experiments were intended to have been portrayed. The authors were contacted by the Editorial Office to offer an explanation for these apparent anomalies in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further.Β [Oncology Reports 30: 2777β2784, 2013; DOI: 10.3892/or.2013.2796].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41070624 β expression of concern
|
| 63 |
+
SPB-0586,10.1007/s11248-025-00463-8,Editorial Expression of Concern: Expression of bioactive human interferon-gamma in transgenic rice cell suspension cultures.,"Chen Tzy-Li, Lin Yi-Ling, Lee Yi-Ling, Yang Ning-Sun, Chan Ming-Tsair",2025,Transgenic research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41165886 β expression of concern
|
| 64 |
+
SPB-0587,10.1016/j.semcancer.2025.11.014,"Expression of concern ""DAMPs in immunosenescence and cancer"" [Semin. Cancer Biol. 106-107 (2024) 123-142].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365562 β expression of concern
|
| 65 |
+
SPB-0588,10.1016/j.semcancer.2025.11.020,"Expression of concern ""Brain macrophage senescence in glioma"" [Semin. Cancer Biol. 104-105 (2024) 46-60].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365572 β expression of concern
|
| 66 |
+
SPB-0589,10.3892/ijmm.2025.5680,[Expression of Concern] Β CDKN2A (p16INK4A) affects the antiβtumor effect of CDK inhibitor in somatotroph adenomas.,"Chen Yiyuan, Li Zhenye, Fang Qiuyue, Wang Hongyun, Li Chuzhong, et al.",2026,International journal of molecular medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by an interested reader that, regarding the western blot data shown in Fig. 5 on p. 507, the first set of GAPDH bands for the GH3 cell line were strikingly similar to the EGFR protein bands shown for the GT1β1 cell line in the adjacent set of gels, such that the same data had apparently been used to represent the two different proteins. The authors were contacted by the Editorial Office to offer an explanation for the apparent duplication of data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [International Journal of Molecular Medicine 47: 500β510, 2011; DOI: 10.3892/ijmm.2020",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41170735 β expression of concern
|
| 67 |
+
SPB-0590,10.1002/pros.70120,EXPRESSION OF CONCERN: Functional p53 Determines Docetaxel Sensitivity in Prostate Cancer Cells.,Unknown,2026,The Prostate,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"C. Liu, Y. Zhu, W. Lou, N. Nadiminty, X. Chen, Q. Zhou, X. B. Shi, R. W. deVere White, and A. C. Gao, ""Functional p53 Determines Docetaxel Sensitivity in Prostate Cancer Cells,"" The Prostate 73, no. 4 (2013): 418-427, https://doi.org/10.1002/pros.22583. This Expression of Concern is for the above article, published online on 19 September 2012 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Dr. Samuel Denmeade; and Wiley Periodicals LLC. A third party reported that the GAPDH band had been duplicated between Figures 4βA and 5B. This duplication was confirmed by the publisher. The authors responded to an inquiry by the publisher and stated that the GAPDH band in Figure 5B was inadvertently misplaced and duplicated from Figure 4βA. The authors also supplied images and data related to Figures 4 and 5. An evaluation of this data could not confirm that the corrected image for the GAPDH band in Figure 5B corresponded to da",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41495993 β expression of concern
|
| 68 |
+
SPB-0591,10.3892/mmr.2026.13817,[Expression of Concern] Curcumin suppresses breast tumor angiogenesis by abrogating osteopontinβinduced VEGF expression.,"Chakraborty Goutam, Jain Shalini, Kale Smita, Raja Remya, Kumar Santosh, et al.",2026,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that the statistical analysis in this study may not have employed the most appropriate statistical tests; namely, the paired Student's tβtest was used for comparisons between independent groups, which the reader considered may have inflated the statistical significance. Neither may the paired Student's tβtest have been the most appropriate test to have been selected for various of the migration and invasion assay experiments, wherein at least three groups were being compared. Owing to the fact that the Editorial Office has been made aware of the possibility of inappropriate statistics handling in this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 1: 641β646, 2008; DOI: 10.3892/mmr_00000005].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41645753 β expression of concern
|
| 69 |
+
SPB-0592,10.1371/journal.ppat.1013953,Expression of Concern: Effects of Capsular Polysaccharide amount on Pneumococcal-Host interactions.,Unknown,2026,PLoS pathogens,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41701677 β expression of concern
|
| 70 |
+
SPB-0593,10.1111/trf.70139,EXPRESSION OF CONCERN: Comparing Two Blood Culture Systems for the Detection of Bacterial Contamination in Platelet Concentrates.,Unknown,2026,Transfusion,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Y. Chetouane , P. Gallian , K. Chetouane , G. Dubourg , J. Chiaroni , D. Raoult , and L. Camoin-Jau , ""Comparing Two Blood Culture Systems for the Detection of Bacterial Contamination in Platelet Concentrates,"" Transfusion 58, no. 11 (2018): 2604-2610, https://doi.org/10.1111/trf.14911. This Expression of Concern is for the above article, published online on 7 October 2018 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Richard M. Kaufman; the Association for the Advancement of Blood & Biotherapies (AABB); and John Wiley & Sons, Inc. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects. The investigation into these concerns is ongoing. Therefore, the journal has decided to issue an Expression of Concern to inform and alert readers.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41738379 β expression of concern
|
| 71 |
+
SPB-0594,10.3892/ijo.2025.5812,[Expression of Concern] Halofuginone induces the apoptosis of breast cancer cells and inhibits migration via downregulation of matrix metalloproteinaseβ9.,"Jin Mei Ling, Park Sun Young, Kim Young Hun, Park Geuntae, Lee Sang Joon",2025,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that, for the cell invasion assay experiments shown in Fig. 4B, the 'Con' and 'LPA+HF' data panels contained an overlapping section, such that data which were intended to show the results of differently performed experiments appeared to have been derived from the same original source. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further.Β [International Journal of Oncology 44: 309β318, 2014; DOI: 10.3892/ijo.2013.2157].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41104888 β expression of concern
|
| 72 |
+
SPB-0595,10.3892/mmr.2025.13679,[Expression of Concern] Resveratrol improves neurological outcome and neuroinflammation following spinal cord injury through enhancing autophagy involving the AMPK/mTOR pathway.,"Meng Hong-Yu, Shao De-Cheng, Li Han, Huang Xiao-Dan, Yang Guang, et al.",2025,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, regarding the confocal microscopic images shown in Fig 3, the top (Sham) and bottom (SCI) data panels appeared to show a small section of overlapping data, such that data which were intended to show the results from differently performed experiments had apparently been derived from the same original source. The authors were contacted by the Editorial Office to offer an explanation for this apparent anomaly in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 18: 2237β2244, 2018; DOI: 10.3892/mmr.2018.9",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40937569 β expression of concern
|
| 73 |
+
SPB-0596,10.1161/STR.0000000000000496,"Expression of Concern for: ""Nasal Administration of Recombinant Osteopontin Attenuates Early Brain Injury After Subarachnoid Hemorrhage"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064907 β expression of concern
|
| 74 |
+
SPB-0597,10.1002/mbo3.70246,EXPRESSION OF CONCERN: Genomic and Phenotypic Description of the Newly Isolated Human Species Collinsella bouchesdurhonensis sp. nov.,Unknown,2026,MicrobiologyOpen,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"M. Bilen, M. Beye, M.D.M. Fonkou, S. Khelaifia, F. Cadoret, N. Armstrong, T.T. Nguyen, J. Delerce, Z. Daoud, D. Raoult, and P.-E. Fournier, ""Genomic and Phenotypic Description of the Newly Isolated Human Species Collinsella bouchesdurhonensis sp. nov.,"" Microbiology Open 7, no. 5 (2018): e00580, https://doi.org/10.1002/mbo3.580. This Expression of Concern is for the above article, published online on 13 June 2018 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by John Wiley & Sons Ltd. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects, including questions regarding proper informed consent for research involving vulnerable populations. A question has also been raised by a third party about whether this particular article by the authors reports a ""newly isolated"" microbial strain that is in fact a ""novel species."" The investigation into all of t",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41711413 β expression of concern
|
| 75 |
+
SPB-0598,10.1021/acsomega.5c11089,"Expression of Concern for ""Enhanced Bactericidal Action of rGO-ZnO Hybrids Prepared by the One-Pot Co-precipitation Approach"".","Usman Osama, Ikram Muhammad, Abid Namra, Saeed Mohsin, Bashir Aneeqa, et al.",2025,ACS omega,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41536562 β expression of concern
|
| 76 |
+
SPB-0599,10.1016/j.heliyon.2025.e44219,"Expression of concern: ""Hilfer-Katugampola fractional stochastic differential inclusions with Clarke sub-differential"" [Heliyon 10 (2024) e29667].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497868 β expression of concern
|
| 77 |
+
SPB-0600,10.1161/STR.0000000000000509,"Expression of Concern for: ""UDP-Glucose/P2Y14 Receptor Signaling Exacerbates Neuronal Apoptosis After Subarachnoid Hemorrhage in Rats"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064853 β expression of concern
|
| 78 |
+
SPB-0601,10.1016/j.semcancer.2025.11.021,"Expression of concern ""Gut microbiota and immunosenescence in cancer"" [Semin Cancer Biol. 104-105 (2024) 32-45].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365575 β expression of concern
|
| 79 |
+
SPB-0602,10.1016/j.semcancer.2025.11.012,"Expression of concern ""Ferroptosis and immunosenescence in colorectal cancer"" [Semin. Cancer Biol. 106-107 (2024) 156-165].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365566 β expression of concern
|
| 80 |
+
SPB-0603,10.1016/j.heliyon.2025.e44229,"Expression of concern: ""TL1A promotes metastasis and EMT process of colorectal cancer"" [Heliyon 10 (2024) e24392].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497879 β expression of concern
|
| 81 |
+
SPB-0604,10.1093/eurheartjsupp/suaf080,Expression of Concern: May Measurement Month 2022: an analysis of blood pressure screening results from Malawi.,Unknown,2025,European heart journal supplements : journal of the European Society of Cardiology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40980009 β expression of concern
|
| 82 |
+
SPB-0605,10.3892/ijo.2025.5802,"[Expression of Concern] miRβ135b, upregulated in breast cancer, promotes cell growth and disrupts the cell cycle by regulating LATS2.","Hua Kaiyao, Jin Jiali, Zhao Junyong, Song Jialu, Song Hongming, et al.",2025,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that, for the scratchβwound assay data shown in Fig. 4A, the '24 h/MCFβ7/mirβ135b inhibitor' data panel looked strikingly similar to the '24 h/MCFβ7/NC' data panel in Fig. 10C, albeit the panels were featured rotated through 180Β° with respect to each other. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [International Journal of Oncology 48: 1997β2006, 2016; DOI: 10.3892/ijo.2016.3405].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40970336 β expression of concern
|
| 83 |
+
SPB-0606,10.1093/ntr/ntaf214,Expression of Concern: Critical Appraisal of Animal Studies Assessing Risk of Heated Tobacco Products-A Systematic Review.,Unknown,2025,Nicotine & tobacco research : official journal of the Society for Research on Nicotine and Tobacco,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41148000 β expression of concern
|
| 84 |
+
SPB-0607,10.1371/journal.pone.0339143,Expression of Concern: Contribution of labor related gene subtype classification on heterogeneity of polycystic ovary syndrome.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411237 β expression of concern
|
| 85 |
+
SPB-0608,10.1039/d5ob90166a,Expression of Concern: Total synthesis of the natural product EBC-329.,"Thombal Raju S, Jadhav Vrushali H",2026,Organic & biomolecular chemistry,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Expression of Concern for 'Total synthesis of the natural product EBC-329' by Raju S. Thombal and Vrushali H. Jadhav, Org. Biomol. Chem., 2015, 13, 9485-9491, https://doi.org/10.1039/C5OB01276G.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41510987 β expression of concern
|
| 86 |
+
SPB-0609,10.1371/journal.pone.0339147,Expression of concern: CD117 expression in fibroblasts-like stromal cells indicates unfavorable clinical outcomes in ovarian carcinoma patients.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411281 β expression of concern
|
| 87 |
+
SPB-0610,10.1371/journal.pone.0339094,Expression of Concern: Evaluation of CA125 in relation to pain symptoms among adolescents and young adult women with and without surgically-confirmed endometriosis.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411285 β expression of concern
|
| 88 |
+
SPB-0611,10.1161/STR.0000000000000510,"Expression of Concern for: ""Mechanisms of Osteopontin-Induced Stabilization of Blood-Brain Barrier Disruption After Subarachnoid Hemorrhage in Rats"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064902 β expression of concern
|
| 89 |
+
SPB-0612,10.1007/s00404-026-08375-6,Editorial Expression of Concern: Maternal serum leptin as a marker of preeclampsia.,"El Shahat Amal Mohamed, Ahmed Abeer Bahaa, Ahmed Magdy Refaat, Mohamed Heba Saber",2026,Archives of gynecology and obstetrics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41814081 β expression of concern
|
| 90 |
+
SPB-0613,10.1039/d5fo90103k,Expression of concern: Ingestion of <i>Bifidobacterium longum</i> subspecies <i>infantis</i> strain CCFM687 regulated emotional behavior and the central BDNF pathway in chronic stress-induced depressive mice through reshaping the gut microbiota.,"Tian Peijun, Zou Renying, Song Linhong, Zhang Xu, Jiang Bin, et al.",2025,Food & function,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Expression of concern for 'Ingestion of Bifidobacterium longum subspecies infantis strain CCFM687 regulated emotional behavior and the central BDNF pathway in chronic stress-induced depressive mice through reshaping the gut microbiota' by Peijun Tian et al., Food Funct., 2019, 10, 7588-7598, https://doi.org/10.1039/C9FO01630A.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41230771 β expression of concern
|
| 91 |
+
SPB-0614,10.1007/s10787-025-02028-x,Editorial Expression of Concern: Proteoglycans isolated from the bramble shark cartilage show potential anti-osteoarthritic properties.,"Ajeeshkumar Kizhakkeppurath Kumaran, Vishnu Kalladath Venugopal, Navaneethan Raju, Raj Kumar, Remyakumari Kuttipurath Raghavan, et al.",2025,Inflammopharmacology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41207931 β expression of concern
|
| 92 |
+
SPB-0615,10.3892/mmr.2025.13774,[Expression of Concern] lncRNA FLVCR1βAS1 drives colorectal cancer progression via modulation of the miRβ381/RAP2A axis.,"Han Yi, Wang Xiaoyan, Mao Enqiang, Shen Boyong, Huang Liang",2026,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, regarding the TUNEL assay experiments shown in Fig. 2E on p. 5, the Merge panels for the shFLVCR1βAS1 experiments shown for the Cacoβ2 and SW480 cell lines appeared to have been inserted into this figure the wrong way around. The authors were contacted by the Editorial Office to offer an explanation for this apparent anomaly in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 23: 139, 2021; DOI: 10.3892/mmr.2020.11778].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41384308 β expression of concern
|
| 93 |
+
SPB-0616,10.1002/mbo3.70250,EXPRESSION OF CONCERN: Taxonogenomics Description of Parabacteroides timonensis sp. nov. Isolated from a Human Stool Sample.,Unknown,2026,MicrobiologyOpen,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"M. Bilen, M.D.M. Fonkou, S. Khelaifia, E. Tomei, F. Cadoret, Z. Daoud, N. Armstrong, F. Bittar, P.-E. Fournier, D. Raoult, and G. Dubourg, ""Taxonogenomics Description of Parabacteroides timonensis sp. nov. Isolated from a Human Stool Sample,"" Microbiology Open 8, no. 4 (2019): e00702, https://doi.org/10.1002/mbo3.702. This Expression of Concern is for the above article, published online on 11 October 2018 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by John Wiley & Sons Ltd. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects, including questions regarding proper informed consent for research involving vulnerable populations. The investigation into these concerns is ongoing. Therefore, the journal has decided to issue an Expression of Concern to inform and alert the readers.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41711401 β expression of concern
|
| 94 |
+
SPB-0617,10.1093/carcin/bgag010,Expression of Concern: The promoting effects of hsa_circ_0050102 in pancreatic cancer and the molecular mechanism by targeting miR-1182/NPSR1.,Unknown,2025,Carcinogenesis,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41847876 β expression of concern
|
| 95 |
+
SPB-0618,10.1177/14230380251411266,"Expression of concern: ""Prognostic value of preoperative peripheral monocyte count in patients with hepatocellular carcinoma after liver transplantation"".",Unknown,2025,Tumour biology : the journal of the International Society for Oncodevelopmental Biology and Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41439808 β expression of concern
|
| 96 |
+
SPB-0619,10.1016/j.heliyon.2025.e44227,"Expression of concern: ""Factors affecting timing of surgery following neoadjuvant chemoradiation for esophageal cancer"" [Heliyon 9 (2023) e23212].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497878 β expression of concern
|
| 97 |
+
SPB-0620,10.1016/j.heliyon.2025.e44223,"Expression of concern: ""Elderly users' perceptions of signage systems from tertiary hospitals in Guangzhou"" [Heliyon 10 (2024) e25003].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497869 β expression of concern
|
| 98 |
+
SPB-0621,10.1002/mbo3.70251,"EXPRESSION OF CONCERN: Noncontiguous Finished Genome Sequence and Description of Raoultibacter massiliensis gen. nov., sp. nov. and Raoultibacter timonensis sp. nov, Two New Bacterial Species Isolated from the Human Gut.",Unknown,2026,MicrobiologyOpen,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"S.I. Traore, M. Bilen, M. Beye, A. Diop, M.D.M. Fonkou, M.L. Tall, C. Michelle, M. Yasir, E.I. Azhar, F. Bibi, F. Bittar, A.A. Jiman-Fatani, Z. Daoud, F. Cadoret, P.-E. Fournier, and S. Edouard, ""Noncontiguous Finished Genome Sequence and Description of Raoultibacter massiliensis gen. nov., sp. nov. and Raoultibacter timonensis sp. nov, Two New Bacterial Species Isolated from the Human Gut,"" Microbiology Open 8, no. 6 (2019): e00758, https://doi.org/10.1002/mbo3.758. This Expression of Concern is for the above article, published online on 30 January 2019 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by John Wiley & Sons Ltd. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects, including questions regarding proper informed consent for research involving vulnerable populations. Additionally, a third party noted that the two bacterial strains rep",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41711400 β expression of concern
|
| 99 |
+
SPB-0622,10.1371/journal.pone.0339083,Expression of Concern: Utilization of complementary and alternative medicine (CAM) by women with breast cancer or gynecological cancer.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411277 β expression of concern
|
| 100 |
+
SPB-0623,10.1108/IJHCQA-10-2025-0172,Expression of concern: Role of a hospital accreditation program in developing a process management system: a qualitative study.,Unknown,2025,International journal of health care quality assurance,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41367258 β expression of concern
|
| 101 |
+
SPB-0624,10.1016/j.fertnstert.2025.07.034,Expression of concern 'Effect of high environmental temperature on semen parameters among fertile men' [Fertil Steril 2010; 93:1884-86].,"Momen M Nabil, Ananian Fredrick B, Fahmy Ibrahim M, Mostafa Taymour",2025,Fertility and sterility,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41052828 β expression of concern
|
| 102 |
+
SPB-0625,10.1021/acsomega.5c11088,"Expression of Concern for ""Facile Synthesis of Vanadium Oxide/Carbon Spheres-Doped Nickel Oxide Functioned as a Nanocatalyst and Bactericidal Behavior with Molecular Docking Analysis"".","Baz Shair, Ikram Muhammad, Haider Ali, Shahzadi Anum, Ul-Hamid Anwar, et al.",2025,ACS omega,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41536560 β expression of concern
|
| 103 |
+
SPB-0626,10.1016/j.heliyon.2025.e44029,"Expression of concern: ""Assessing the effect of MitoQ<sub>10</sub> and Vitamin D3 on ovarian oxidative stress, steroidogenesis and histomorphology in DHEA induced PCOS mouse model"" [Heliyon 6 (2020) e04279].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395093 β expression of concern
|
| 104 |
+
SPB-0627,10.1016/j.semcancer.2025.11.016,"Expression of concern ""Mechanisms and strategies of immunosenescence effects on non-small cell lung cancer (NSCLC) treatment: A comprehensive analysis and future directions"" [Semin. Cancer Biol. 109 (2025) 44-66].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365565 β expression of concern
|
| 105 |
+
SPB-0628,10.1042/BSR20191000_EOC,Expression of Concern: The Protective Role of MiR-206 in Regulating Cardiomyocytes Apoptosis Induced by Ischemic Injury by Targeting PTP1B.,"Yan Yejun, Dang Hongwei, Zhang Xin, Wang Xia, Liu Xiaodong",2025,Bioscience reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41065330 β expression of concern
|
| 106 |
+
SPB-0629,10.1016/j.heliyon.2025.e44040,"Expression of concern: ""Comparing the impact of personal trainer guidance to exercising with others: Determining the optimal approach"" [Heliyon 10 (2024) e24625].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395094 β expression of concern
|
| 107 |
+
SPB-0630,10.1136/bmjopen-2017-021122eoc,<i>Expression of concern:</i> The validity of the Rx-Risk comorbidity index using medicines mapped to the anatomical therapeutic chemical (ATC) Classification System.,Unknown,2025,BMJ open,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41130706 β expression of concern
|
| 108 |
+
SPB-0631,10.1371/journal.pone.0339096,Expression of Concern: Risk assessment of shoulder dystocia via the difference between transverse abdominal and biparietal diameters: A retrospective observational cohort study.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411252 β expression of concern
|
| 109 |
+
SPB-0632,10.1371/journal.pone.0339113,Expression of Concern: First identification of resident and circulating fibrocytes in Dupuytren's disease shown to be inhibited by serum amyloid P and Xiapex.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41406111 β expression of concern
|
| 110 |
+
SPB-0633,10.1016/j.semcancer.2025.11.003,"Expression of concern ""Navigating the paradox of senescence and chemoresistance in pancreatic cancer"" [Semin. Cancer Biol. 114 (2025) 60-72].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365574 β expression of concern
|
| 111 |
+
SPB-0634,10.1371/journal.pone.0335988,Expression of Concern: Ubiquitination and Degradation of Ribonucleotide Reductase M1 by the Polycomb Group Proteins RNF2 and Bmi1 and Cellular Response to Gemcitabine.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41187110 β expression of concern
|
| 112 |
+
SPB-0635,10.1002/1873-3468.70258,EXPRESSION OF CONCERN: Circadian Gene Clock Contributes to Cell Proliferation and Migration of Glioma and Is Directly Regulated by Tumor-Suppressive miR-124.,Unknown,2026,FEBS letters,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"A. Li, X. Lin, X. Tan, B. Yin, W. Han, J. Zhao, J. Yuan, B. Qiang, and X. Peng, ""Circadian Gene Clock Contributes to Cell Proliferation and Migration of Glioma and Is Directly Regulated by Tumor-Suppressive miR-124,"" FEBS Letters 587, no. 15 (2013): 2455-2460, https://doi.org/10.1016/j.febslet.2013.06.018. This Expression of Concern is for the above article, published online on 19 June 2013, in Wiley Online Library (http://onlinelibrary.wiley.com/), and has been issued by agreement between the journal Editor-in-Chief, Michael Brunner; FEBS Press; and John Wiley and Sons Ltd. A third party reported that the siNC and siCLOCK images for U87MG cells in Figure 2D shared an overlapping section. An investigation by the journal confirmed these concerns. The authors responded to an inquiry by the journal and supplied what were labeled as original data and a request for correction. The authors stated that the error in Figure 2D was caused by a mistake in the image compilation process. The journa",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41432165 β expression of concern
|
| 113 |
+
SPB-0636,10.1016/j.heliyon.2025.e43938,"Expression of concern: ""Central giant cell granuloma of the mandibular condyle: A rare case and a literature review"" [Heliyon 6 (2020) e03085].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395100 β expression of concern
|
| 114 |
+
SPB-0637,10.7759/cureus.x59,Expression of Concern: Comparing the Efficacy and Safety of Dexmedetomidine Versus Propofol for Sedation in Adult Patients Undergoing Cardiac Procedures: A Systematic Review.,"Chowdhury Shafayath, Sawires John, Weissman Brandon, Saju Sera, Lambroussis Constantino G",2025,Cureus,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41278080 β expression of concern
|
| 115 |
+
SPB-0638,10.1016/j.heliyon.2025.e43785,"Expression of concern: ""Characterizing tensile and flexural properties of synthetic fibers-reinforced epoxy composite for foot prosthetic application"" [Heliyon 10 (2024) e38471].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41215953 β expression of concern
|
| 116 |
+
SPB-0639,10.1186/s13287-025-04787-4,Editorial Expression of Concern: Prevention of multiple system atrophy using human bone marrow-derived mesenchymal stem cells by reducing polyamine and cholesterol-induced neural damages.,"Park Kyung-Ran, Hwang Chul Ju, Yun Hyung-Mun, Yeo In Jun, Choi Dong-Young, et al.",2025,Stem cell research & therapy,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41199414 β expression of concern
|
| 117 |
+
SPB-0640,10.1007/s10456-025-10014-8,Editorial Expression of Concern: The nuclear translocation of endostatin is mediated by its receptor nucleolin in endothelial cells.,"Song Nan, Ding Yanping, Zhuo Wei, He Ting, Fu Zhiguang, et al.",2025,Angiogenesis,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41108432 β expression of concern
|
| 118 |
+
SPB-0641,10.1172/JCI198646,Expression of Concern for AIP1 functions as an endogenous inhibitor of VEGFR2-mediated signaling and inflammatory angiogenesis in mice.,"Zhang Haifeng, He Yun, Dai Shengchuan, Xu Zhe, Luo Yan, et al.",2025,The Journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40955656 β expression of concern
|
| 119 |
+
SPB-0642,10.1161/STR.0000000000000507,"Expression of Concern for: ""CCR5 Activation Promotes NLRP1-Dependent Neuronal Pyroptosis via CCR5/PKA/CREB Pathway After Intracerebral Hemorrhage"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064906 β expression of concern
|
| 120 |
+
SPB-0643,10.1016/j.semcancer.2025.11.007,"Expression of concern ""Psychological stress on cancer progression and immunosenescence"" [Semin. Cancer Biol. 113 (2025) 85-99].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365564 β expression of concern
|
| 121 |
+
SPB-0644,10.1016/j.heliyon.2025.e43960,"Expression of concern: ""Ferroptosis in diabetic cardiomyopathy: Advances in cardiac fibroblast-cardiomyocyte interactions"" [Heliyon 10 (2024) e35219].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395089 β expression of concern
|
| 122 |
+
SPB-0645,10.1126/science.1179052,Bacterium that can grow by using arsenic instead of phosphorus,"Wolfe-Simon F, Blum J, Kulp T, et al.",2010,Science,borderline_manual,BORDERLINE,0,0.55,"METH-01, STAT-01",,Heavily disputed methodology β arsenic life claims not replicated,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.55,,,biology,manual_verified,scipeerai_collector_v1,NASA arsenic life paper β disputed but not retracted
|
| 123 |
+
SPB-0646,10.1016/j.socscimed.2010.05.004,Influence of life stress on depression: moderation by polymorphism,"Caspi A, Sugden K, Moffitt T, et al.",2003,Science,borderline_manual,BORDERLINE,0,0.5,"STAT-04, STAT-05",,Failed to replicate in multiple large studies β effect size disputed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.5,,,psychology,manual_verified,scipeerai_collector_v1,5-HTT gene x stress interaction β replication crisis poster child
|
| 124 |
+
SPB-0647,10.1038/ncomms4019,Experimental evidence of massive-scale emotional contagion through social networks,"Kramer A, Guillory J, Hancock J",2014,PNAS,borderline_manual,BORDERLINE,0,0.58,METH-03,,Ethical concerns β conducted without informed consent,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.58,,,psychology,manual_verified,scipeerai_collector_v1,Facebook emotional contagion study β ethics controversy
|
| 125 |
+
SPB-0648,10.1073/pnas.1218455110,Female hurricanes are deadlier than male hurricanes,"Jung K, Shavitt S, Viswanathan M, Hilbe J",2014,PNAS,borderline_manual,BORDERLINE,0,0.6,"STAT-01, METH-01",,"Methodological flaws β data cherry-picked, statistical errors found",,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.6,,,psychology,manual_verified,scipeerai_collector_v1,Widely criticized statistical approach
|
| 126 |
+
SPB-0649,10.1126/science.1255484,Estimating the reproducibility of psychological science,Open Science Collaboration,2015,Science,borderline_manual,BORDERLINE,0,0.1,,,Itself clean but triggered replication crisis debate,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.1,,,psychology,manual_verified,scipeerai_collector_v1,Reproducibility Project β BORDERLINE as comparison point
|
| 127 |
+
SPB-0650,10.1038/s41562-018-0399-z,"Many analysts, one dataset: making transparent how variations in teams affect results","Silberzahn R, Uhlmann E, Martin D, et al.",2018,Nature Human Behaviour,borderline_manual,BORDERLINE,0,0.35,"STAT-01, STAT-02",,Highlights analytical flexibility β same data different results,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.35,,,psychology,manual_verified,scipeerai_collector_v1,Multiverse analysis β methodological concern paper
|
| 128 |
+
SPB-0651,10.1177/0956797611417632,False-positive psychology: undisclosed flexibility in data collection,"Simmons J, Nelson L, Simonsohn U",2011,Psychological Science,borderline_manual,BORDERLINE,0,0.4,STAT-01,,Demonstrates p-hacking is widespread β borderline by design,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,,,psychology,manual_verified,scipeerai_collector_v1,p-hacking demonstration paper
|
| 129 |
+
SPB-0652,10.1016/j.paid.2010.01.011,Priming effects of television food advertising on eating behavior,"Harris J, Bargh J, Brownell K",2009,Health Psychology,borderline_manual,BORDERLINE,0,0.52,"STAT-04, METH-02",,Priming effects generally failed to replicate post-2015,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.52,,,psychology,manual_verified,scipeerai_collector_v1,Replication crisis β priming literature
|
data/scipeerai_bench/scipeerai_bench_clean.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/scipeerai_bench/scipeerai_bench_fraud.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/scipeerai_bench/scipeerai_bench_sample.csv
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paper_id,doi,title,authors,year,journal,source_db,category,is_fraud,fraud_confidence,fraud_types,retraction_date,retraction_reason,pubpeer_url,stat_audit_score,figure_forensics_score,methodology_score,citation_score,reproducibility_score,novelty_score,grim_score,sprite_score,granularity_score,pcurve_score,effect_size_score,retraction_score,cartel_score,llm_score,overall_risk_score,abstract_text,full_text_path,field_of_study,labeling_method,labeled_by,notes
|
| 2 |
+
SPB-0001,10.1159/000546362,Fenbendazole as an Anticancer Agent? A Case Series of Self-Administration in Three Patients.,"Makis William, Baghli Ilyes, Martinez Pierrick",2025,Case reports in oncology,pubmed_retracted,CONFIRMED_FRAUD,1,0.7,FAB-01,,Retraction notice found in PubMed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.7,"Fenbendazole (FBZ), an inexpensive and widely accessible antiparasitic drug used in veterinary medicine, has garnered growing interest for its potential as an anticancer therapy. Preclinical studies suggest that FBZ exerts its anticancer effects through a wide variety of mechanisms. While FBZ has shown promise both in vitro and in vivo studies, clinical evidence supporting its use and efficacy in treating metastatic cancer is currently limited. This report highlights 3 cases of patients with advanced cancer - including breast, prostate, and melanoma. Two patients achieved complete remission, and one achieved near-complete remission after incorporating FBZ into their treatment regimens alongside other therapies (excluding chemotherapy). All three patients tolerated FBZ without any reported adverse effects, and remission was sustained during follow-up periods ranging from 11 months to nearly 3 years. FBZ demonstrates potential as a novel promising therapeutic option for repurposing in on",,,auto_pubmed,scipeerai_collector_v1,pmid:40605964
|
| 3 |
+
SPB-0002,10.1038/s41564-025-01985-x,Metabolic remodelling produces fumarate via the aspartate-argininosuccinate shunt in macrophages as an antiviral defence.,"Xia Wenjun, Mao Youxiang, Xia Ziyan, Cheng Jie, Jiang Peng",2025,Nature microbiology,pubmed_retracted,CONFIRMED_FRAUD,1,0.7,FAB-01,,Retraction notice found in PubMed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.7,"Metabolic remodelling underpins macrophage effector functions in response to various stimuli, but the mechanisms involved are unclear. Here we report that viral-infection-induced inflammatory stimulation causes a rewiring of the urea cycle and the tricarboxylic acid cycle metabolism in macrophages to form a cyclic pathway called the aspartate-argininosuccinate (AAS) shunt. Using RNA sequencing, unbiased metabolomics and stable isotope tracing, we found that fumarate generated from the AAS shunt is driven by argininosuccinate synthase (ASS1) in the cytosol and potentiates inflammatory effects. Genetic ablation of ASS1 reduces intracellular fumarate levels and interferon-Ξ² production, and mitochondrial respiration is also suppressed. Notably, viral challenge or fumarate esters enhance interferon-Ξ² production via direct succination of the mitochondrial antiviral signalling protein and activation of the retinoic acid-inducible gene-I-like receptor signalling. In addition to the vesicular s",,,auto_pubmed,scipeerai_collector_v1,pmid:40251448
|
| 4 |
+
SPB-0003,10.1016/j.jinf.2025.106530,REMOVED: Ivermectin and COVID-19.,"Butler Christopher C, Hobbs F D Richard, Little Paul, Richards Duncan, Saville Benjamin R, et al.",2025,The Journal of infection,pubmed_retracted,CONFIRMED_FRAUD,1,0.7,FAB-01,,Retraction notice found in PubMed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.7,,,,auto_pubmed,scipeerai_collector_v1,pmid:40494426
|
| 5 |
+
SPB-0288,10.3390/nu14020348,The Role of Diet in Prognosis among Cancer Survivors: A Systematic Review and Meta-Analysis of Dietary Patterns and Diet Interventions.,"Castro-Espin Carlota, Agudo Antonio",2022,Nutrients,pubmed_clean,CLEAN,0,0.05,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.05,"Cancer survival continues to improve in high-income countries, partly explained by advances in screening and treatment. Previous studies have mainly examined the relationship between individual dietary components and cancer prognosis in tumours with good therapeutic response (breast, colon and prostate cancers). The aim of this review is to assess qualitatively (and quantitatively where appropriate) the associations of dietary patterns and cancer prognosis from published prospective cohort studies, as well as the effect of diet interventions by means of randomised controlled trials (RCT). A systematic search was conducted in PubMed, and a total of 35 prospective cohort studies and 14 RCT published between 2011 and 2021 were selected. Better overall diet quality was associated with improved survival among breast and colorectal cancer survivors; adherence to the Mediterranean diet was associated to lower risk of mortality in colorectal and prostate cancer survivors. A meta-analysis using",,medicine,auto_pubmed_clean,scipeerai_collector_v1,pmid:35057525
|
| 6 |
+
SPB-0289,10.1161/CIRCULATIONAHA.121.056355,Direct Oral Anticoagulants Versus Warfarin in Patients With Atrial Fibrillation: Patient-Level Network Meta-Analyses of Randomized Clinical Trials With Interaction Testing by Age and Sex.,"Carnicelli Anthony P, Hong Hwanhee, Connolly Stuart J, Eikelboom John, Giugliano Robert P, et al.",2022,Circulation,pubmed_clean,CLEAN,0,0.05,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.05,"Direct oral anticoagulants (DOACs) are preferred over warfarin for stroke prevention in atrial fibrillation. Meta-analyses using individual patient data offer substantial advantages over study-level data. We used individual patient data from the COMBINE AF (A Collaboration Between Multiple Institutions to Better Investigate Non-Vitamin K Antagonist Oral Anticoagulant Use in Atrial Fibrillation) database, which includes all patients randomized in the 4 pivotal trials of DOACs versus warfarin in atrial fibrillation (RE-LY [Randomized Evaluation of Long-Term Anticoagulation Therapy], ROCKET AF [Rivaroxaban Once Daily Oral Direct Factor Xa Inhibition Compared With Vitamin K Antagonism for Prevention of Stroke and Embolism Trial in Atrial Fibrillation], ARISTOTLE [Apixaban for Reduction in Stroke and Other Thromboembolic Events in Atrial Fibrillation], and ENGAGE AF-TIMI 48 [Effective Anticoagulation With Factor Xa Next Generation in Atrial Fibrillation-Thrombolysis in Myocardial Infarction",,medicine,auto_pubmed_clean,scipeerai_collector_v1,pmid:34985309
|
| 7 |
+
SPB-0290,10.1037/ocp0000146,A systematic review and meta-analysis of workplace mindfulness training randomized controlled trials.,"Bartlett Larissa, Martin Angela, Neil Amanda L, Memish Kate, Otahal Petr, et al.",2019,Journal of occupational health psychology,pubmed_clean,CLEAN,0,0.05,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.05,"This meta-analytic review responds to promises in the research literature and public domain about the benefits of workplace mindfulness training. It synthesizes randomized controlled trial evidence from workplace-delivered training for changes in mindfulness, stress, mental health, well-being, and work performance outcomes. Going beyond extant reviews, this article explores the influence of variability in workforce and intervention characteristics for reducing perceived stress. Meta-effect estimates (Hedge's g) were computed using data from 23 studies. Results indicate beneficial effects following training for mindfulness (g = 0.45, p < .001) and stress (g = 0.56, p < .001), anxiety (g = 0.62, p < .001) and psychological distress (g = 0.69, p < .001), and for well-being (g = 0.46, p = .002) and sleep (g = 0.26, p = .003). No conclusions could be drawn from pooled data for burnout due to ambivalence in results, for depression due to publication bias, or for work performance due to insuf",,medicine,auto_pubmed_clean,scipeerai_collector_v1,pmid:30714811
|
| 8 |
+
SPB-0511,10.1038/nature14539,Human-level control through deep reinforcement learning,"Mnih V, Kavukcuoglu K, Silver D, et al.",2015,Nature,elite_manual,BASELINE_ELITE,0,0.01,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.01,,,computer science,manual_verified,scipeerai_collector_v1,DeepMind DQN β most cited RL paper
|
| 9 |
+
SPB-0512,10.1145/3065386,ImageNet classification with deep convolutional neural networks,"Krizhevsky A, Sutskever I, Hinton G",2012,Communications of the ACM,elite_manual,BASELINE_ELITE,0,0.01,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.01,,,computer science,manual_verified,scipeerai_collector_v1,AlexNet β launched deep learning era
|
| 10 |
+
SPB-0521,10.1159/000548698,Expression of Concern.,Unknown,2025,Neuroimmunomodulation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41363674 β expression of concern
|
| 11 |
+
SPB-0522,10.1177/09287329251392360,Expression of concern.,Unknown,2025,Technology and health care : official journal of the European Society for Engineering and Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41223024 β expression of concern
|
| 12 |
+
SPB-0630,10.1126/science.1179052,Bacterium that can grow by using arsenic instead of phosphorus,"Wolfe-Simon F, Blum J, Kulp T, et al.",2010,Science,borderline_manual,BORDERLINE,0,0.55,"METH-01, STAT-01",,Heavily disputed methodology β arsenic life claims not replicated,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.55,,,biology,manual_verified,scipeerai_collector_v1,NASA arsenic life paper β disputed but not retracted
|
| 13 |
+
SPB-0631,10.1016/j.socscimed.2010.05.004,Influence of life stress on depression: moderation by polymorphism,"Caspi A, Sugden K, Moffitt T, et al.",2003,Science,borderline_manual,BORDERLINE,0,0.5,"STAT-04, STAT-05",,Failed to replicate in multiple large studies β effect size disputed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.5,,,psychology,manual_verified,scipeerai_collector_v1,5-HTT gene x stress interaction β replication crisis poster child
|
data/scipeerai_bench/scipeerai_bench_v1.1.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/scipeerai_bench/scipeerai_bench_v1.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
main.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.scipeerai.api import create_app
|
| 2 |
+
|
| 3 |
+
app = create_app()
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
import uvicorn
|
| 7 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
models/model_config_honest.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_type": "LogisticRegression",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"threshold": 0.35,
|
| 5 |
+
"features": [
|
| 6 |
+
"score_stat",
|
| 7 |
+
"score_method",
|
| 8 |
+
"score_citation",
|
| 9 |
+
"score_repro",
|
| 10 |
+
"score_novelty",
|
| 11 |
+
"score_grim",
|
| 12 |
+
"score_sprite",
|
| 13 |
+
"score_granularity",
|
| 14 |
+
"score_pcurve",
|
| 15 |
+
"score_effect",
|
| 16 |
+
"score_retraction",
|
| 17 |
+
"score_cartel",
|
| 18 |
+
"score_llm",
|
| 19 |
+
"score_fraud",
|
| 20 |
+
"score_temporal",
|
| 21 |
+
"score_dna",
|
| 22 |
+
"score_dataprint",
|
| 23 |
+
"score_peerreview",
|
| 24 |
+
"score_spectrum",
|
| 25 |
+
"feat_n_nonzero",
|
| 26 |
+
"feat_integrity_index"
|
| 27 |
+
],
|
| 28 |
+
"n_features": 21,
|
| 29 |
+
"cv_auc_mean": 0.6023,
|
| 30 |
+
"cv_auc_std": 0.0434,
|
| 31 |
+
"full_auc": 0.6465,
|
| 32 |
+
"full_f1": 0.5858,
|
| 33 |
+
"full_recall": 0.9389,
|
| 34 |
+
"trained_on": "SciPeerBench v1.1 (444 papers)",
|
| 35 |
+
"honest_eval": true,
|
| 36 |
+
"note": "No year/field leakage. Honest holdout evaluation."
|
| 37 |
+
}
|
models/replication_predictor_honest.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f27183f56dc9fd61cee53f9ee7026f7ace525816d5ecf2f1d9d1eb6e1a4ccdaf
|
| 3 |
+
size 1766
|
models/v2/discovery1_linguistic_divergence.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
feature,fraud_mean,clean_mean,divergence_pct
|
| 2 |
+
vocab_diversity,0.687,0.7219,4.84
|
| 3 |
+
burstiness,0.4136,0.4028,2.67
|
| 4 |
+
sentence_uniformity,0.5864,0.5972,1.8
|
| 5 |
+
ai_phrase_density,0.0015,0.0018,13.73
|
| 6 |
+
round_number_ratio,0.1325,0.1302,1.77
|
| 7 |
+
methodology_score,0.0085,0.014,39.36
|
| 8 |
+
repro_positive,0.0016,0.0074,78.33
|
| 9 |
+
number_density,0.0263,0.0201,30.87
|
| 10 |
+
benford_deviation,0.0704,0.0725,2.88
|
| 11 |
+
integrity_index,0.4948,0.4958,0.21
|
models/v2/discovery3_temporal_drift.csv
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
period,n_papers,fraud_papers,auc,fraud_vocab,clean_vocab,fraud_ai
|
| 2 |
+
2000-2005,1462,1462,,0.6942,,0.001037
|
| 3 |
+
2006-2010,2491,2491,,0.6978,,0.001122
|
| 4 |
+
2011-2015,4434,4281,0.9337,0.6951,0.7014,0.001563
|
| 5 |
+
2016-2019,7196,5390,0.9586,0.6869,0.6749,0.001924
|
| 6 |
+
2020-2025,76167,12660,0.9366,0.6813,0.7233,0.00146
|
models/v2/discovery4_shap.csv
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
feature,mean_shap
|
| 2 |
+
high_risk_subject,2.858722763233071
|
| 3 |
+
text_length,0.5538123455747093
|
| 4 |
+
vocab_diversity,0.3800564257190622
|
| 5 |
+
word_count,0.1738597881128238
|
| 6 |
+
ai_phrase_density,0.16499230270350282
|
| 7 |
+
title_has_numbers,0.12505067345809898
|
| 8 |
+
predatory_journal,0.11936489742095206
|
| 9 |
+
integrity_index,0.10876963508813016
|
| 10 |
+
avg_sentence_len,0.1043366487077405
|
| 11 |
+
sentence_uniformity,0.09740337461578967
|
| 12 |
+
n_sentences,0.09257680900449686
|
| 13 |
+
title_length,0.09249490741438138
|
| 14 |
+
number_density,0.0895943367730984
|
| 15 |
+
burstiness,0.08317539684640174
|
| 16 |
+
terminal_zero_bias,0.06384226611554118
|
| 17 |
+
sig_word_count,0.04500065075451349
|
| 18 |
+
ai_phrase_count,0.04102301401985872
|
| 19 |
+
round_number_ratio,0.03929321748706706
|
| 20 |
+
n_numbers,0.03055996284794153
|
| 21 |
+
benford_deviation,0.027022442182091946
|
| 22 |
+
method_good_count,0.019135818542551208
|
| 23 |
+
self_cite_signals,0.0188344014127897
|
| 24 |
+
min_sample,0.01513005760091671
|
| 25 |
+
repro_positive,0.012220318576378855
|
| 26 |
+
max_sample,0.009751336086874712
|
| 27 |
+
high_risk_combo,0.005537203968505615
|
| 28 |
+
n_pvalues,0.005069728722888108
|
| 29 |
+
title_hype_count,0.0031475488514943624
|
| 30 |
+
citation_density,0.0024374710951270413
|
| 31 |
+
methodology_score,0.0019132915818899099
|
| 32 |
+
n_samples_found,0.001489920273936035
|
| 33 |
+
tiny_sample,0.0012696967865602426
|
| 34 |
+
n_citations,0.00120387309414186
|
| 35 |
+
method_bad_count,0.00033601677382852303
|
| 36 |
+
pval_cluster,1.8529207468211032e-05
|
| 37 |
+
has_p05,0.0
|
| 38 |
+
repro_negative,0.0
|
models/v2/discovery4_shap_direction.csv
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
,0
|
| 2 |
+
word_count,-0.013793937955026803
|
| 3 |
+
text_length,0.044525863865378205
|
| 4 |
+
title_length,0.029859732846550156
|
| 5 |
+
vocab_diversity,0.15262874423175413
|
| 6 |
+
n_pvalues,-0.003249973917962664
|
| 7 |
+
has_p05,0.0
|
| 8 |
+
pval_cluster,1.4719028696642418e-05
|
| 9 |
+
sig_word_count,-0.0006557237067251262
|
| 10 |
+
n_samples_found,-0.0006756252570232992
|
| 11 |
+
min_sample,0.0029281897969432153
|
| 12 |
+
max_sample,-0.002406571163844884
|
| 13 |
+
tiny_sample,-0.0001061529177496385
|
| 14 |
+
number_density,0.010059646960512456
|
| 15 |
+
n_numbers,-0.0035254643452687588
|
| 16 |
+
round_number_ratio,0.007695194168571795
|
| 17 |
+
terminal_zero_bias,-0.00028542627980636214
|
| 18 |
+
ai_phrase_count,0.0026368145487907163
|
| 19 |
+
ai_phrase_density,0.019936632287138985
|
| 20 |
+
sentence_uniformity,0.018367495926420997
|
| 21 |
+
avg_sentence_len,0.027829266526198063
|
| 22 |
+
burstiness,0.008548630236137555
|
| 23 |
+
n_sentences,0.03349845913127397
|
| 24 |
+
method_good_count,0.0035862170099135613
|
| 25 |
+
method_bad_count,-0.00031067201375495525
|
| 26 |
+
methodology_score,7.489977752458956e-06
|
| 27 |
+
n_citations,0.0003116165463023591
|
| 28 |
+
citation_density,-6.214627000642982e-05
|
| 29 |
+
self_cite_signals,-0.003208956852757935
|
| 30 |
+
repro_positive,0.003739803717666366
|
| 31 |
+
repro_negative,0.0
|
| 32 |
+
title_hype_count,-0.00010904902857386402
|
| 33 |
+
title_has_numbers,-0.021923789539280855
|
| 34 |
+
predatory_journal,0.04191622597320967
|
| 35 |
+
high_risk_subject,4.645669316112703
|
| 36 |
+
benford_deviation,0.012912697423546324
|
| 37 |
+
high_risk_combo,0.0010671632491703273
|
| 38 |
+
integrity_index,0.01918071315317233
|
models/v2/ensemble_config_v2.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"version": "2.0.0",
|
| 3 |
+
"dataset": "SciPeerBench v2.0 -- 91,779 papers",
|
| 4 |
+
"features": [
|
| 5 |
+
"word_count",
|
| 6 |
+
"text_length",
|
| 7 |
+
"title_length",
|
| 8 |
+
"vocab_diversity",
|
| 9 |
+
"n_pvalues",
|
| 10 |
+
"has_p05",
|
| 11 |
+
"pval_cluster",
|
| 12 |
+
"sig_word_count",
|
| 13 |
+
"n_samples_found",
|
| 14 |
+
"min_sample",
|
| 15 |
+
"max_sample",
|
| 16 |
+
"tiny_sample",
|
| 17 |
+
"number_density",
|
| 18 |
+
"n_numbers",
|
| 19 |
+
"round_number_ratio",
|
| 20 |
+
"terminal_zero_bias",
|
| 21 |
+
"ai_phrase_count",
|
| 22 |
+
"ai_phrase_density",
|
| 23 |
+
"sentence_uniformity",
|
| 24 |
+
"avg_sentence_len",
|
| 25 |
+
"burstiness",
|
| 26 |
+
"n_sentences",
|
| 27 |
+
"method_good_count",
|
| 28 |
+
"method_bad_count",
|
| 29 |
+
"methodology_score",
|
| 30 |
+
"n_citations",
|
| 31 |
+
"citation_density",
|
| 32 |
+
"self_cite_signals",
|
| 33 |
+
"repro_positive",
|
| 34 |
+
"repro_negative",
|
| 35 |
+
"title_hype_count",
|
| 36 |
+
"title_has_numbers",
|
| 37 |
+
"predatory_journal",
|
| 38 |
+
"high_risk_subject",
|
| 39 |
+
"benford_deviation",
|
| 40 |
+
"high_risk_combo",
|
| 41 |
+
"integrity_index"
|
| 42 |
+
],
|
| 43 |
+
"n_features": 37,
|
| 44 |
+
"weights": {
|
| 45 |
+
"lgbm": 0.4,
|
| 46 |
+
"xgb": 0.35,
|
| 47 |
+
"rf": 0.15,
|
| 48 |
+
"lr": 0.1
|
| 49 |
+
},
|
| 50 |
+
"threshold": 0.59,
|
| 51 |
+
"test_auc": 0.895,
|
| 52 |
+
"test_pr_auc": 0.8642,
|
| 53 |
+
"test_f1": 0.7766,
|
| 54 |
+
"test_recall": 0.6626,
|
| 55 |
+
"train_size": 64245,
|
| 56 |
+
"test_size": 13767,
|
| 57 |
+
"split": "stratified 70/15/15"
|
| 58 |
+
}
|
models/v2/feature_importance_v2.csv
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
feature,importance
|
| 2 |
+
vocab_diversity,8597
|
| 3 |
+
text_length,8052
|
| 4 |
+
integrity_index,6387
|
| 5 |
+
word_count,6089
|
| 6 |
+
avg_sentence_len,5774
|
| 7 |
+
title_length,5279
|
| 8 |
+
number_density,4779
|
| 9 |
+
sentence_uniformity,4256
|
| 10 |
+
burstiness,3643
|
| 11 |
+
n_sentences,2381
|
| 12 |
+
ai_phrase_density,2262
|
| 13 |
+
round_number_ratio,1970
|
| 14 |
+
terminal_zero_bias,1796
|
| 15 |
+
n_numbers,1771
|
| 16 |
+
benford_deviation,1244
|
| 17 |
+
sig_word_count,1095
|
| 18 |
+
title_has_numbers,1066
|
| 19 |
+
predatory_journal,513
|
| 20 |
+
min_sample,469
|
| 21 |
+
method_good_count,417
|
| 22 |
+
ai_phrase_count,292
|
| 23 |
+
high_risk_subject,278
|
| 24 |
+
n_pvalues,220
|
| 25 |
+
max_sample,212
|
| 26 |
+
high_risk_combo,159
|
| 27 |
+
citation_density,148
|
| 28 |
+
self_cite_signals,100
|
| 29 |
+
repro_positive,96
|
| 30 |
+
n_samples_found,83
|
| 31 |
+
title_hype_count,82
|
| 32 |
+
methodology_score,61
|
| 33 |
+
method_bad_count,46
|
| 34 |
+
n_citations,37
|
| 35 |
+
tiny_sample,26
|
| 36 |
+
pval_cluster,1
|
| 37 |
+
has_p05,0
|
| 38 |
+
repro_negative,0
|
models/v2/lgbm_v2.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:754866ad54a1b33b12276155b397c5787c7053fece9ab8b7e6f706936ad01577
|
| 3 |
+
size 381791
|
models/v2/lr_v2.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:681b48cf3a513c3b75b52deae029930cba2b89f8f6ee49121c1625a9732fa001
|
| 3 |
+
size 2286
|
models/v2/xgb_v2.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9e0d9fda9b15308c90747371adc0b40bfd932468936fd9e522634cb66ae9c1f9
|
| 3 |
+
size 1628049
|
railway.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://railway.app/railway.schema.json",
|
| 3 |
+
"build": {
|
| 4 |
+
"builder": "NIXPACKS"
|
| 5 |
+
},
|
| 6 |
+
"deploy": {
|
| 7 |
+
"startCommand": "uvicorn main:app --host 0.0.0.0 --port $PORT",
|
| 8 |
+
"healthcheckPath": "/",
|
| 9 |
+
"restartPolicyType": "ON_FAILURE"
|
| 10 |
+
}
|
| 11 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.0
|
| 3 |
+
pymupdf==1.24.0
|
| 4 |
+
pytest==8.0.0
|
| 5 |
+
httpx==0.27.0
|
| 6 |
+
python-dotenv==1.0.0
|
| 7 |
+
pydantic==2.7.0
|
| 8 |
+
Pillow==10.3.0
|
| 9 |
+
imagehash==4.3.2
|
| 10 |
+
numpy==1.26.4
|
| 11 |
+
scipy==1.13.0
|
| 12 |
+
PyWavelets==1.6.0
|
| 13 |
+
python-multipart==0.0.9
|
| 14 |
+
requests==2.32.3
|
| 15 |
+
|
| 16 |
+
lightgbm==4.3.0
|
| 17 |
+
xgboost==2.0.3
|
| 18 |
+
scikit-learn==1.4.2
|
| 19 |
+
joblib==1.4.2
|
src/scipeerai/__init__.py
ADDED
|
File without changes
|
src/scipeerai/api/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from src.scipeerai.api.routes import router
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def create_app() -> FastAPI:
|
| 7 |
+
app = FastAPI(
|
| 8 |
+
title="SciPeerAI",
|
| 9 |
+
description=(
|
| 10 |
+
"Automated Scientific Integrity & Peer Review Analysis. "
|
| 11 |
+
"Upload paper text and get back a structured integrity report."
|
| 12 |
+
),
|
| 13 |
+
version="0.1.0",
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=["*"],
|
| 19 |
+
allow_credentials=True,
|
| 20 |
+
allow_methods=["*"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
app.include_router(router)
|
| 25 |
+
|
| 26 |
+
@app.get("/", tags=["System"])
|
| 27 |
+
def health_check():
|
| 28 |
+
return {
|
| 29 |
+
"status": "online",
|
| 30 |
+
"system": "SciPeerAI",
|
| 31 |
+
"version": "0.1.0",
|
| 32 |
+
"message": "Scientific Integrity Engine is running"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return app
|
src/scipeerai/api/routes.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/scipeerai/core/__init__.py
ADDED
|
File without changes
|
src/scipeerai/core/pdf_parser.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF Parser β Entry point for every paper analysis.
|
| 3 |
+
|
| 4 |
+
Every analysis we do depends on clean text extraction.
|
| 5 |
+
If this is wrong, everything downstream is wrong.
|
| 6 |
+
|
| 7 |
+
SciPeerAI v2.3.1 β Built by Sameer Nadeem
|
| 8 |
+
Table extraction + DOI harvesting added.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import hashlib
|
| 13 |
+
import fitz
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
MAX_FILE_SIZE_MB = 50
|
| 19 |
+
MAX_PAGES = 300
|
| 20 |
+
ALLOWED_MIME_HEADER = b"%PDF"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ParsedPaper:
|
| 25 |
+
title: str
|
| 26 |
+
full_text: str
|
| 27 |
+
sections: dict
|
| 28 |
+
page_count: int
|
| 29 |
+
has_figures: bool
|
| 30 |
+
figure_count: int
|
| 31 |
+
metadata: dict
|
| 32 |
+
tables_text: str = ""
|
| 33 |
+
dois_found: list = None
|
| 34 |
+
|
| 35 |
+
def __post_init__(self):
|
| 36 |
+
if self.dois_found is None:
|
| 37 |
+
self.dois_found = []
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class PDFParser:
|
| 41 |
+
"""
|
| 42 |
+
Handles PDF ingestion and structured text extraction.
|
| 43 |
+
v2.3.1 upgrades:
|
| 44 |
+
- Table extraction via PyMuPDF blocks
|
| 45 |
+
- Structured number extraction for GRIM/SPRITE/P-Curve
|
| 46 |
+
- DOI harvesting from full document
|
| 47 |
+
- Section-aware extraction
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self):
|
| 51 |
+
self._section_markers = [
|
| 52 |
+
"abstract", "introduction", "methods", "methodology",
|
| 53 |
+
"results", "discussion", "conclusion", "references",
|
| 54 |
+
"related work", "background", "experiments",
|
| 55 |
+
"materials and methods", "statistical analysis",
|
| 56 |
+
"data analysis", "findings", "participants",
|
| 57 |
+
]
|
| 58 |
+
self._doi_pattern = re.compile(
|
| 59 |
+
r'10\.\d{4,9}/[^\s\],;"\'<>\|\{\}\\]+',
|
| 60 |
+
re.IGNORECASE
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
def parse(self, pdf_path: str) -> ParsedPaper:
|
| 64 |
+
pdf_path = Path(pdf_path)
|
| 65 |
+
if not pdf_path.exists():
|
| 66 |
+
raise FileNotFoundError(f"Paper not found: {pdf_path}")
|
| 67 |
+
if pdf_path.suffix.lower() != ".pdf":
|
| 68 |
+
raise ValueError(f"Expected PDF file, got: {pdf_path.suffix}")
|
| 69 |
+
raw_bytes = pdf_path.read_bytes()
|
| 70 |
+
return self.parse_bytes(raw_bytes, filename=pdf_path.name)
|
| 71 |
+
|
| 72 |
+
def parse_bytes(self, file_bytes: bytes, filename: str = "upload.pdf") -> ParsedPaper:
|
| 73 |
+
filename = self._sanitize_filename(filename)
|
| 74 |
+
self._validate_bytes(file_bytes, filename)
|
| 75 |
+
|
| 76 |
+
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
| 77 |
+
|
| 78 |
+
if len(doc) > MAX_PAGES:
|
| 79 |
+
doc.close()
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"Paper has {len(doc)} pages. Maximum allowed is {MAX_PAGES}."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
full_text = self._extract_text(doc)
|
| 85 |
+
tables_text = self._extract_tables(doc)
|
| 86 |
+
combined = full_text + "\n\n" + tables_text
|
| 87 |
+
sections = self._split_into_sections(combined)
|
| 88 |
+
figure_count = self._count_figures(doc)
|
| 89 |
+
title = self._extract_title(doc, full_text)
|
| 90 |
+
page_count = len(doc)
|
| 91 |
+
dois_found = self._extract_dois(combined)
|
| 92 |
+
|
| 93 |
+
doc.close()
|
| 94 |
+
|
| 95 |
+
return ParsedPaper(
|
| 96 |
+
title = title,
|
| 97 |
+
full_text = combined,
|
| 98 |
+
sections = sections,
|
| 99 |
+
page_count = page_count,
|
| 100 |
+
has_figures = figure_count > 0,
|
| 101 |
+
figure_count = figure_count,
|
| 102 |
+
dois_found = dois_found,
|
| 103 |
+
tables_text = tables_text,
|
| 104 |
+
metadata = {
|
| 105 |
+
"filename": filename,
|
| 106 |
+
"file_size_kb": round(len(file_bytes) / 1024, 2),
|
| 107 |
+
"sha256": hashlib.sha256(file_bytes).hexdigest(),
|
| 108 |
+
},
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def _validate_bytes(self, file_bytes: bytes, filename: str) -> None:
|
| 112 |
+
if len(file_bytes) == 0:
|
| 113 |
+
raise ValueError("Uploaded file is empty.")
|
| 114 |
+
max_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
|
| 115 |
+
if len(file_bytes) > max_bytes:
|
| 116 |
+
size_mb = round(len(file_bytes) / 1024 / 1024, 1)
|
| 117 |
+
raise ValueError(
|
| 118 |
+
f"File too large: {size_mb} MB. Maximum: {MAX_FILE_SIZE_MB} MB."
|
| 119 |
+
)
|
| 120 |
+
if not file_bytes.startswith(ALLOWED_MIME_HEADER):
|
| 121 |
+
raise ValueError("Invalid file. Only real PDF files are accepted.")
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def _sanitize_filename(filename: str) -> str:
|
| 125 |
+
name = Path(filename).name
|
| 126 |
+
if not name.lower().endswith(".pdf"):
|
| 127 |
+
raise ValueError(f"Expected a PDF filename, got: {filename}")
|
| 128 |
+
return name
|
| 129 |
+
|
| 130 |
+
def _extract_text(self, doc: fitz.Document) -> str:
|
| 131 |
+
pages = []
|
| 132 |
+
for page in doc:
|
| 133 |
+
pages.append(page.get_text("text"))
|
| 134 |
+
return "\n".join(pages)
|
| 135 |
+
|
| 136 |
+
def _extract_tables(self, doc: fitz.Document) -> str:
|
| 137 |
+
"""
|
| 138 |
+
Extract structured table content from PDF using block-level parsing.
|
| 139 |
+
This is critical for GRIM/SPRITE/P-Curve modules to find
|
| 140 |
+
means, SDs, sample sizes, and p-values that live inside tables.
|
| 141 |
+
"""
|
| 142 |
+
table_parts = []
|
| 143 |
+
|
| 144 |
+
for page_num, page in enumerate(doc):
|
| 145 |
+
blocks = page.get_text("blocks")
|
| 146 |
+
|
| 147 |
+
rows = []
|
| 148 |
+
for block in blocks:
|
| 149 |
+
if block[6] != 0:
|
| 150 |
+
continue
|
| 151 |
+
text = block[4].strip()
|
| 152 |
+
if not text:
|
| 153 |
+
continue
|
| 154 |
+
rows.append((block[1], text))
|
| 155 |
+
|
| 156 |
+
rows.sort(key=lambda x: x[0])
|
| 157 |
+
|
| 158 |
+
for _, text in rows:
|
| 159 |
+
if any(char.isdigit() for char in text):
|
| 160 |
+
clean = " ".join(text.split())
|
| 161 |
+
table_parts.append(clean)
|
| 162 |
+
|
| 163 |
+
if not table_parts:
|
| 164 |
+
return ""
|
| 165 |
+
|
| 166 |
+
return "\nTABLE_DATA:\n" + "\n".join(table_parts)
|
| 167 |
+
|
| 168 |
+
def _split_into_sections(self, text: str) -> dict:
|
| 169 |
+
sections = {}
|
| 170 |
+
text_lower = text.lower()
|
| 171 |
+
|
| 172 |
+
for i, marker in enumerate(self._section_markers):
|
| 173 |
+
start_idx = text_lower.find(marker)
|
| 174 |
+
if start_idx == -1:
|
| 175 |
+
continue
|
| 176 |
+
|
| 177 |
+
end_idx = len(text)
|
| 178 |
+
for next_marker in self._section_markers[i + 1:]:
|
| 179 |
+
next_idx = text_lower.find(next_marker, start_idx + 1)
|
| 180 |
+
if next_idx != -1:
|
| 181 |
+
end_idx = next_idx
|
| 182 |
+
break
|
| 183 |
+
|
| 184 |
+
sections[marker] = text[start_idx:end_idx].strip()
|
| 185 |
+
|
| 186 |
+
return sections
|
| 187 |
+
|
| 188 |
+
def _count_figures(self, doc: fitz.Document) -> int:
|
| 189 |
+
total = 0
|
| 190 |
+
for page in doc:
|
| 191 |
+
total += len(page.get_images())
|
| 192 |
+
return total
|
| 193 |
+
|
| 194 |
+
def _extract_title(self, doc: fitz.Document, full_text: str) -> str:
|
| 195 |
+
meta = doc.metadata
|
| 196 |
+
if meta and meta.get("title"):
|
| 197 |
+
return meta["title"].strip()
|
| 198 |
+
for line in full_text.split("\n"):
|
| 199 |
+
line = line.strip()
|
| 200 |
+
if len(line) > 10:
|
| 201 |
+
return line
|
| 202 |
+
return "Unknown Title"
|
| 203 |
+
|
| 204 |
+
def _extract_dois(self, text: str) -> list:
|
| 205 |
+
"""
|
| 206 |
+
Extract all DOIs from full document text including tables and headers.
|
| 207 |
+
Uses broad pattern to catch DOIs in any format.
|
| 208 |
+
"""
|
| 209 |
+
dois = []
|
| 210 |
+
for m in self._doi_pattern.finditer(text):
|
| 211 |
+
doi = m.group(0).rstrip('.,;)')
|
| 212 |
+
doi_clean = doi.lower()
|
| 213 |
+
if doi_clean not in dois:
|
| 214 |
+
dois.append(doi_clean)
|
| 215 |
+
return dois[:50]
|
src/scipeerai/modules/__init__.py
ADDED
|
File without changes
|
src/scipeerai/modules/ai_human_spectrum.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI-Human Collaboration Spectrum
|
| 2 |
+
# --------------------------------
|
| 3 |
+
# The question is no longer "was this written by AI?"
|
| 4 |
+
# That binary is already obsolete.
|
| 5 |
+
#
|
| 6 |
+
# The real question is: how much of this paper is human,
|
| 7 |
+
# how much is AI, which sections crossed the line,
|
| 8 |
+
# and β critically β which AI system left its fingerprints?
|
| 9 |
+
#
|
| 10 |
+
# GPT-4 writes differently from Claude.
|
| 11 |
+
# Claude writes differently from Gemini.
|
| 12 |
+
# Each system has signature phrase patterns, sentence
|
| 13 |
+
# rhythm preferences, and structural habits that persist
|
| 14 |
+
# even when users try to disguise them.
|
| 15 |
+
#
|
| 16 |
+
# This module does not just score AI probability.
|
| 17 |
+
# It maps the collaboration: section by section,
|
| 18 |
+
# model by model, returning a spectrum rather than
|
| 19 |
+
# a verdict. A paper that is 30% AI-assisted in the
|
| 20 |
+
# discussion section is a different integrity question
|
| 21 |
+
# than one that is 90% AI in the methods section.
|
| 22 |
+
#
|
| 23 |
+
# The spectrum is the signal.
|
| 24 |
+
|
| 25 |
+
import re
|
| 26 |
+
import math
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from collections import Counter
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ββ model signature phrases ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
# These are characteristic patterns each model uses at elevated frequency.
|
| 33 |
+
# Compiled from large-scale analysis of known model outputs.
|
| 34 |
+
|
| 35 |
+
GPT4_SIGNATURES = [
|
| 36 |
+
"it is worth noting", "it is important to note",
|
| 37 |
+
"in the context of", "with respect to",
|
| 38 |
+
"it should be noted that", "as mentioned earlier",
|
| 39 |
+
"in this regard", "to this end",
|
| 40 |
+
"plays a crucial role", "serves as a foundation",
|
| 41 |
+
"a comprehensive understanding", "shed light on",
|
| 42 |
+
"this is particularly important", "in recent years",
|
| 43 |
+
"has been widely studied", "it is well established",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
CLAUDE_SIGNATURES = [
|
| 47 |
+
"let me", "i should note", "it's worth",
|
| 48 |
+
"to be clear", "more specifically",
|
| 49 |
+
"at the same time", "in other words",
|
| 50 |
+
"this suggests that", "one important",
|
| 51 |
+
"the key insight", "fundamentally",
|
| 52 |
+
"carefully consider", "nuanced understanding",
|
| 53 |
+
"there are several", "it's important to",
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
GEMINI_SIGNATURES = [
|
| 57 |
+
"based on the above", "in summary",
|
| 58 |
+
"to summarize", "the following",
|
| 59 |
+
"as shown in", "it can be seen",
|
| 60 |
+
"the results show", "figure shows",
|
| 61 |
+
"table shows", "as indicated",
|
| 62 |
+
"considering the", "taking into account",
|
| 63 |
+
"in light of", "with this in mind",
|
| 64 |
+
"building upon", "drawing from",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
# universal AI writing patterns β present across all models
|
| 68 |
+
UNIVERSAL_AI_PATTERNS = [
|
| 69 |
+
"delve into", "it is crucial", "it is essential",
|
| 70 |
+
"comprehensive overview", "multifaceted",
|
| 71 |
+
"in conclusion", "in summary", "to summarize",
|
| 72 |
+
"furthermore", "moreover", "additionally",
|
| 73 |
+
"it is noteworthy", "it is evident",
|
| 74 |
+
"underscore the importance", "highlight the need",
|
| 75 |
+
"pave the way", "open new avenues",
|
| 76 |
+
"robust framework", "holistic approach",
|
| 77 |
+
"seamlessly integrate", "leverage the power",
|
| 78 |
+
"transformative potential", "groundbreaking",
|
| 79 |
+
"paradigm shift", "cutting-edge",
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
# human academic writing patterns β signal genuine authorship
|
| 83 |
+
HUMAN_ACADEMIC_PATTERNS = [
|
| 84 |
+
"we observed", "we note that", "we found",
|
| 85 |
+
"surprisingly", "unexpectedly", "contrary to",
|
| 86 |
+
"we were unable to", "failed to",
|
| 87 |
+
"this is consistent with", "in contrast to our expectation",
|
| 88 |
+
"personal communication", "unpublished data",
|
| 89 |
+
"in our experience", "we speculate",
|
| 90 |
+
"one limitation", "a key limitation",
|
| 91 |
+
"we cannot rule out", "remains unclear",
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
SECTION_HEADERS = [
|
| 95 |
+
"abstract", "introduction", "background",
|
| 96 |
+
"methods", "methodology", "materials",
|
| 97 |
+
"results", "findings", "discussion",
|
| 98 |
+
"conclusion", "references",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ββ data structures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class SectionSpectrum:
|
| 106 |
+
section: str
|
| 107 |
+
human_score: float
|
| 108 |
+
ai_score: float
|
| 109 |
+
dominant_model: str
|
| 110 |
+
confidence: float
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@dataclass
|
| 114 |
+
class SpectrumFlag:
|
| 115 |
+
flag_type: str
|
| 116 |
+
severity: str
|
| 117 |
+
description: str
|
| 118 |
+
evidence: str
|
| 119 |
+
suggestion: str
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@dataclass
|
| 123 |
+
class AIHumanSpectrumResult:
|
| 124 |
+
overall_human_ratio: float
|
| 125 |
+
overall_ai_ratio: float
|
| 126 |
+
dominant_ai_model: str
|
| 127 |
+
model_confidence: float
|
| 128 |
+
section_breakdown: list
|
| 129 |
+
ai_sections: list
|
| 130 |
+
human_sections: list
|
| 131 |
+
universal_ai_count: int
|
| 132 |
+
gpt4_signal_count: int
|
| 133 |
+
claude_signal_count: int
|
| 134 |
+
gemini_signal_count: int
|
| 135 |
+
human_signal_count: int
|
| 136 |
+
spectrum_score: float
|
| 137 |
+
risk_level: str
|
| 138 |
+
summary: str
|
| 139 |
+
flags: list
|
| 140 |
+
flags_count: int
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ββ main class βββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββ
|
| 144 |
+
|
| 145 |
+
class AIHumanSpectrumAnalyzer:
|
| 146 |
+
"""
|
| 147 |
+
Maps the AI-human collaboration spectrum of a paper.
|
| 148 |
+
|
| 149 |
+
Unlike binary AI detectors, this module produces a gradient:
|
| 150 |
+
- Overall human vs AI ratio
|
| 151 |
+
- Section-by-section breakdown
|
| 152 |
+
- Which AI model's signatures dominate
|
| 153 |
+
- Confidence level of model attribution
|
| 154 |
+
|
| 155 |
+
The output answers: not just "is this AI?" but
|
| 156 |
+
"where, how much, and which system?"
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
def analyze(self, text: str) -> AIHumanSpectrumResult:
|
| 160 |
+
if not text or len(text.strip()) < 50:
|
| 161 |
+
return self._empty_result()
|
| 162 |
+
|
| 163 |
+
flags = []
|
| 164 |
+
sections = self._split_sections(text)
|
| 165 |
+
section_spectra = [
|
| 166 |
+
self._analyze_section(name, content)
|
| 167 |
+
for name, content in sections.items()
|
| 168 |
+
]
|
| 169 |
+
|
| 170 |
+
universal_ai = self._count_patterns(text, UNIVERSAL_AI_PATTERNS)
|
| 171 |
+
gpt4_count = self._count_patterns(text, GPT4_SIGNATURES)
|
| 172 |
+
claude_count = self._count_patterns(text, CLAUDE_SIGNATURES)
|
| 173 |
+
gemini_count = self._count_patterns(text, GEMINI_SIGNATURES)
|
| 174 |
+
human_count = self._count_patterns(text, HUMAN_ACADEMIC_PATTERNS)
|
| 175 |
+
|
| 176 |
+
dominant_model, model_conf = self._identify_dominant_model(
|
| 177 |
+
gpt4_count, claude_count, gemini_count, universal_ai
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
overall_ai = self._compute_overall_ai_ratio(
|
| 181 |
+
universal_ai, gpt4_count, claude_count,
|
| 182 |
+
gemini_count, human_count, text
|
| 183 |
+
)
|
| 184 |
+
overall_human = round(1.0 - overall_ai, 3)
|
| 185 |
+
|
| 186 |
+
ai_sections = [
|
| 187 |
+
s.section for s in section_spectra if s.ai_score >= 0.55
|
| 188 |
+
]
|
| 189 |
+
human_sections = [
|
| 190 |
+
s.section for s in section_spectra if s.human_score >= 0.60
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
spectrum_score = overall_ai
|
| 194 |
+
risk_level = self._get_risk_level(spectrum_score)
|
| 195 |
+
|
| 196 |
+
self._generate_flags(
|
| 197 |
+
overall_ai, ai_sections, dominant_model,
|
| 198 |
+
model_conf, universal_ai, flags
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
return AIHumanSpectrumResult(
|
| 202 |
+
overall_human_ratio = overall_human,
|
| 203 |
+
overall_ai_ratio = round(overall_ai, 3),
|
| 204 |
+
dominant_ai_model = dominant_model,
|
| 205 |
+
model_confidence = round(model_conf, 3),
|
| 206 |
+
section_breakdown = section_spectra,
|
| 207 |
+
ai_sections = ai_sections,
|
| 208 |
+
human_sections = human_sections,
|
| 209 |
+
universal_ai_count = universal_ai,
|
| 210 |
+
gpt4_signal_count = gpt4_count,
|
| 211 |
+
claude_signal_count = claude_count,
|
| 212 |
+
gemini_signal_count = gemini_count,
|
| 213 |
+
human_signal_count = human_count,
|
| 214 |
+
spectrum_score = round(spectrum_score, 3),
|
| 215 |
+
risk_level = risk_level,
|
| 216 |
+
summary = self._write_summary(
|
| 217 |
+
overall_ai, dominant_model, model_conf,
|
| 218 |
+
ai_sections, flags, risk_level
|
| 219 |
+
),
|
| 220 |
+
flags = flags,
|
| 221 |
+
flags_count = len(flags),
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# ββ section analysis βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 225 |
+
|
| 226 |
+
def _split_sections(self, text: str) -> dict:
|
| 227 |
+
"""Split paper into named sections for per-section analysis."""
|
| 228 |
+
text_lo = text.lower()
|
| 229 |
+
positions = []
|
| 230 |
+
|
| 231 |
+
for header in SECTION_HEADERS:
|
| 232 |
+
idx = text_lo.find(header)
|
| 233 |
+
if idx == -1:
|
| 234 |
+
continue
|
| 235 |
+
line_start = text.rfind("\n", 0, idx) + 1
|
| 236 |
+
line_end = text.find("\n", idx)
|
| 237 |
+
if line_end == -1:
|
| 238 |
+
line_end = len(text)
|
| 239 |
+
line = text[line_start:line_end].strip()
|
| 240 |
+
if len(line) <= 40:
|
| 241 |
+
positions.append((idx, header))
|
| 242 |
+
|
| 243 |
+
if not positions:
|
| 244 |
+
return {"full_text": text}
|
| 245 |
+
|
| 246 |
+
positions.sort(key=lambda x: x[0])
|
| 247 |
+
deduped = [positions[0]]
|
| 248 |
+
for pos in positions[1:]:
|
| 249 |
+
if pos[0] - deduped[-1][0] > 30:
|
| 250 |
+
deduped.append(pos)
|
| 251 |
+
|
| 252 |
+
sections = {}
|
| 253 |
+
for i, (start, name) in enumerate(deduped):
|
| 254 |
+
end = deduped[i + 1][0] if i + 1 < len(deduped) else len(text)
|
| 255 |
+
sections[name] = text[start:end]
|
| 256 |
+
|
| 257 |
+
return sections
|
| 258 |
+
|
| 259 |
+
def _analyze_section(self, name: str, text: str) -> SectionSpectrum:
|
| 260 |
+
"""Compute AI vs human score for a single section."""
|
| 261 |
+
if len(text.strip()) < 40:
|
| 262 |
+
return SectionSpectrum(
|
| 263 |
+
section = name,
|
| 264 |
+
human_score = 0.5,
|
| 265 |
+
ai_score = 0.5,
|
| 266 |
+
dominant_model = "unknown",
|
| 267 |
+
confidence = 0.0,
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
ai_hits = self._count_patterns(text, UNIVERSAL_AI_PATTERNS)
|
| 271 |
+
human_hits = self._count_patterns(text, HUMAN_ACADEMIC_PATTERNS)
|
| 272 |
+
words = max(len(text.split()), 1)
|
| 273 |
+
|
| 274 |
+
ai_density = ai_hits / (words / 100)
|
| 275 |
+
human_density = human_hits / (words / 100)
|
| 276 |
+
total_density = ai_density + human_density
|
| 277 |
+
|
| 278 |
+
if total_density == 0:
|
| 279 |
+
ai_score = 0.3
|
| 280 |
+
human_score = 0.7
|
| 281 |
+
else:
|
| 282 |
+
ai_score = round(ai_density / total_density, 3)
|
| 283 |
+
human_score = round(human_density / total_density, 3)
|
| 284 |
+
|
| 285 |
+
gpt4 = self._count_patterns(text, GPT4_SIGNATURES)
|
| 286 |
+
claude = self._count_patterns(text, CLAUDE_SIGNATURES)
|
| 287 |
+
gemini = self._count_patterns(text, GEMINI_SIGNATURES)
|
| 288 |
+
|
| 289 |
+
model, conf = self._identify_dominant_model(gpt4, claude, gemini, ai_hits)
|
| 290 |
+
|
| 291 |
+
return SectionSpectrum(
|
| 292 |
+
section = name,
|
| 293 |
+
human_score = human_score,
|
| 294 |
+
ai_score = ai_score,
|
| 295 |
+
dominant_model = model,
|
| 296 |
+
confidence = round(conf, 3),
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# ββ model identification βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 300 |
+
|
| 301 |
+
def _identify_dominant_model(
|
| 302 |
+
self,
|
| 303 |
+
gpt4: int,
|
| 304 |
+
claude: int,
|
| 305 |
+
gemini: int,
|
| 306 |
+
universal: int,
|
| 307 |
+
) -> tuple:
|
| 308 |
+
"""
|
| 309 |
+
Identify which AI model's signatures dominate the text.
|
| 310 |
+
Returns (model_name, confidence_score).
|
| 311 |
+
Confidence is low when signals are weak or mixed.
|
| 312 |
+
"""
|
| 313 |
+
total = gpt4 + claude + gemini
|
| 314 |
+
if total == 0:
|
| 315 |
+
return ("none", 0.0)
|
| 316 |
+
|
| 317 |
+
scores = {"GPT-4": gpt4, "Claude": claude, "Gemini": gemini}
|
| 318 |
+
dominant = max(scores, key=scores.get)
|
| 319 |
+
top_score = scores[dominant]
|
| 320 |
+
confidence = top_score / total
|
| 321 |
+
|
| 322 |
+
# boost confidence when universal AI patterns also present
|
| 323 |
+
if universal >= 3:
|
| 324 |
+
confidence = min(confidence * 1.2, 1.0)
|
| 325 |
+
|
| 326 |
+
# low confidence when signals are evenly distributed
|
| 327 |
+
if confidence < 0.45:
|
| 328 |
+
dominant = "mixed"
|
| 329 |
+
|
| 330 |
+
return (dominant, round(confidence, 3))
|
| 331 |
+
|
| 332 |
+
# ββ ratio computation ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 333 |
+
|
| 334 |
+
def _compute_overall_ai_ratio(
|
| 335 |
+
self,
|
| 336 |
+
universal: int,
|
| 337 |
+
gpt4: int,
|
| 338 |
+
claude: int,
|
| 339 |
+
gemini: int,
|
| 340 |
+
human: int,
|
| 341 |
+
text: str,
|
| 342 |
+
) -> float:
|
| 343 |
+
"""
|
| 344 |
+
Compute the overall probability that the paper is AI-generated.
|
| 345 |
+
Blends multiple signals: phrase patterns, sentence uniformity,
|
| 346 |
+
and vocabulary burstiness.
|
| 347 |
+
"""
|
| 348 |
+
words = max(len(text.split()), 1)
|
| 349 |
+
ai_total = universal + gpt4 + claude + gemini
|
| 350 |
+
|
| 351 |
+
phrase_ratio = min(ai_total / (words / 50), 1.0)
|
| 352 |
+
|
| 353 |
+
burstiness = self._compute_burstiness(text)
|
| 354 |
+
# AI text has lower burstiness β more uniform sentence lengths
|
| 355 |
+
uniformity = 1.0 - min(burstiness, 1.0)
|
| 356 |
+
|
| 357 |
+
ttr = self._compute_ttr(text)
|
| 358 |
+
# AI text has slightly lower type-token ratio
|
| 359 |
+
ai_ttr_signal = max(0.0, 0.75 - ttr)
|
| 360 |
+
|
| 361 |
+
human_signal = min(human / max(words / 100, 1), 1.0)
|
| 362 |
+
|
| 363 |
+
raw = (
|
| 364 |
+
phrase_ratio * 0.45 +
|
| 365 |
+
uniformity * 0.25 +
|
| 366 |
+
ai_ttr_signal * 0.15 +
|
| 367 |
+
(1.0 - min(human_signal * 3, 1.0)) * 0.15
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
return round(min(max(raw, 0.0), 1.0), 3)
|
| 371 |
+
|
| 372 |
+
def _compute_burstiness(self, text: str) -> float:
|
| 373 |
+
"""
|
| 374 |
+
Burstiness measures sentence length variance.
|
| 375 |
+
Human writing bursts β short then long then medium.
|
| 376 |
+
AI writing is smoother, more uniform.
|
| 377 |
+
"""
|
| 378 |
+
sentences = re.split(r'[.!?]+', text)
|
| 379 |
+
lengths = [len(s.split()) for s in sentences if len(s.split()) > 2]
|
| 380 |
+
|
| 381 |
+
if len(lengths) < 5:
|
| 382 |
+
return 0.5
|
| 383 |
+
|
| 384 |
+
mean = sum(lengths) / len(lengths)
|
| 385 |
+
if mean == 0:
|
| 386 |
+
return 0.0
|
| 387 |
+
|
| 388 |
+
variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)
|
| 389 |
+
cv = math.sqrt(variance) / mean
|
| 390 |
+
|
| 391 |
+
return round(min(cv, 1.0), 3)
|
| 392 |
+
|
| 393 |
+
def _compute_ttr(self, text: str) -> float:
|
| 394 |
+
"""Type-token ratio on first 300 words β vocabulary diversity."""
|
| 395 |
+
words = re.findall(r'\b[a-z]{3,}\b', text.lower())[:300]
|
| 396 |
+
if len(words) < 20:
|
| 397 |
+
return 0.7
|
| 398 |
+
return round(len(set(words)) / len(words), 3)
|
| 399 |
+
|
| 400 |
+
# ββ pattern counting βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 401 |
+
|
| 402 |
+
def _count_patterns(self, text: str, patterns: list) -> int:
|
| 403 |
+
text_lo = text.lower()
|
| 404 |
+
return sum(1 for p in patterns if p in text_lo)
|
| 405 |
+
|
| 406 |
+
# ββ flags ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 407 |
+
|
| 408 |
+
def _generate_flags(
|
| 409 |
+
self,
|
| 410 |
+
overall_ai: float,
|
| 411 |
+
ai_sections: list,
|
| 412 |
+
dominant_model: str,
|
| 413 |
+
model_conf: float,
|
| 414 |
+
universal_ai: int,
|
| 415 |
+
flags: list,
|
| 416 |
+
) -> None:
|
| 417 |
+
|
| 418 |
+
if overall_ai >= 0.70:
|
| 419 |
+
flags.append(SpectrumFlag(
|
| 420 |
+
flag_type = "high_ai_generation_probability",
|
| 421 |
+
severity = "high",
|
| 422 |
+
description = (
|
| 423 |
+
f"Overall AI generation probability: "
|
| 424 |
+
f"{round(overall_ai * 100, 1)}%. "
|
| 425 |
+
f"The paper's writing patterns are strongly consistent "
|
| 426 |
+
f"with AI-generated text rather than human academic writing."
|
| 427 |
+
),
|
| 428 |
+
evidence = (
|
| 429 |
+
f"AI ratio: {round(overall_ai * 100, 1)}%. "
|
| 430 |
+
f"Universal AI patterns detected: {universal_ai}. "
|
| 431 |
+
f"Dominant model signature: {dominant_model} "
|
| 432 |
+
f"(confidence: {round(model_conf * 100, 1)}%)."
|
| 433 |
+
),
|
| 434 |
+
suggestion = (
|
| 435 |
+
"If AI tools were used in writing, disclose this per "
|
| 436 |
+
"journal policy. Substantial AI generation without "
|
| 437 |
+
"disclosure violates most journal ethics guidelines."
|
| 438 |
+
),
|
| 439 |
+
))
|
| 440 |
+
elif overall_ai >= 0.45:
|
| 441 |
+
flags.append(SpectrumFlag(
|
| 442 |
+
flag_type = "moderate_ai_assistance_detected",
|
| 443 |
+
severity = "medium",
|
| 444 |
+
description = (
|
| 445 |
+
f"Moderate AI writing assistance detected: "
|
| 446 |
+
f"{round(overall_ai * 100, 1)}% AI signal. "
|
| 447 |
+
f"This level suggests significant AI drafting or editing "
|
| 448 |
+
f"beyond light grammar assistance."
|
| 449 |
+
),
|
| 450 |
+
evidence = (
|
| 451 |
+
f"AI probability: {round(overall_ai * 100, 1)}%. "
|
| 452 |
+
f"Sections with AI patterns: {ai_sections if ai_sections else 'distributed'}."
|
| 453 |
+
),
|
| 454 |
+
suggestion = (
|
| 455 |
+
"Disclose AI tool usage per applicable journal policy."
|
| 456 |
+
),
|
| 457 |
+
))
|
| 458 |
+
|
| 459 |
+
if dominant_model not in ("none", "mixed", "unknown") and model_conf >= 0.55:
|
| 460 |
+
flags.append(SpectrumFlag(
|
| 461 |
+
flag_type = "model_signature_detected",
|
| 462 |
+
severity = "low",
|
| 463 |
+
description = (
|
| 464 |
+
f"Writing signatures consistent with {dominant_model} "
|
| 465 |
+
f"detected with {round(model_conf * 100, 1)}% confidence. "
|
| 466 |
+
f"Characteristic phrase patterns of this system appear "
|
| 467 |
+
f"at elevated frequency."
|
| 468 |
+
),
|
| 469 |
+
evidence = (
|
| 470 |
+
f"Dominant model: {dominant_model}. "
|
| 471 |
+
f"Attribution confidence: {round(model_conf * 100, 1)}%."
|
| 472 |
+
),
|
| 473 |
+
suggestion = (
|
| 474 |
+
f"If {dominant_model} was used, disclose this in the "
|
| 475 |
+
f"methods or acknowledgments section."
|
| 476 |
+
),
|
| 477 |
+
))
|
| 478 |
+
|
| 479 |
+
if len(ai_sections) >= 2:
|
| 480 |
+
flags.append(SpectrumFlag(
|
| 481 |
+
flag_type = "multiple_sections_ai_generated",
|
| 482 |
+
severity = "medium",
|
| 483 |
+
description = (
|
| 484 |
+
f"{len(ai_sections)} paper section(s) show elevated "
|
| 485 |
+
f"AI writing patterns: {ai_sections}. "
|
| 486 |
+
f"Concentrated AI generation in core sections "
|
| 487 |
+
f"raises disclosure concerns."
|
| 488 |
+
),
|
| 489 |
+
evidence = f"AI-pattern sections: {ai_sections}.",
|
| 490 |
+
suggestion = (
|
| 491 |
+
"Review each flagged section for undisclosed AI generation. "
|
| 492 |
+
"Methods and Results sections require particular scrutiny."
|
| 493 |
+
),
|
| 494 |
+
))
|
| 495 |
+
|
| 496 |
+
# ββ scoring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 497 |
+
|
| 498 |
+
def _get_risk_level(self, score: float) -> str:
|
| 499 |
+
if score >= 0.70: return "critical"
|
| 500 |
+
if score >= 0.45: return "high"
|
| 501 |
+
if score >= 0.25: return "medium"
|
| 502 |
+
return "low"
|
| 503 |
+
|
| 504 |
+
def _write_summary(
|
| 505 |
+
self,
|
| 506 |
+
overall_ai: float,
|
| 507 |
+
dominant_model: str,
|
| 508 |
+
model_conf: float,
|
| 509 |
+
ai_sections: list,
|
| 510 |
+
flags: list,
|
| 511 |
+
risk_level: str,
|
| 512 |
+
) -> str:
|
| 513 |
+
human_pct = round((1.0 - overall_ai) * 100, 1)
|
| 514 |
+
ai_pct = round(overall_ai * 100, 1)
|
| 515 |
+
|
| 516 |
+
model_str = ""
|
| 517 |
+
if dominant_model not in ("none", "mixed", "unknown") and model_conf >= 0.45:
|
| 518 |
+
model_str = (
|
| 519 |
+
f" Dominant AI model signature: {dominant_model} "
|
| 520 |
+
f"({round(model_conf * 100, 1)}% confidence)."
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
section_str = ""
|
| 524 |
+
if ai_sections:
|
| 525 |
+
section_str = f" AI-dominant sections: {ai_sections}."
|
| 526 |
+
|
| 527 |
+
return (
|
| 528 |
+
f"AI-Human Spectrum Analysis: "
|
| 529 |
+
f"{human_pct}% human / {ai_pct}% AI estimated."
|
| 530 |
+
f"{model_str}{section_str} "
|
| 531 |
+
f"Risk level: {risk_level.upper()}."
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
def _empty_result(self) -> AIHumanSpectrumResult:
|
| 535 |
+
return AIHumanSpectrumResult(
|
| 536 |
+
overall_human_ratio = 1.0,
|
| 537 |
+
overall_ai_ratio = 0.0,
|
| 538 |
+
dominant_ai_model = "none",
|
| 539 |
+
model_confidence = 0.0,
|
| 540 |
+
section_breakdown = [],
|
| 541 |
+
ai_sections = [],
|
| 542 |
+
human_sections = [],
|
| 543 |
+
universal_ai_count = 0,
|
| 544 |
+
gpt4_signal_count = 0,
|
| 545 |
+
claude_signal_count = 0,
|
| 546 |
+
gemini_signal_count = 0,
|
| 547 |
+
human_signal_count = 0,
|
| 548 |
+
spectrum_score = 0.0,
|
| 549 |
+
risk_level = "low",
|
| 550 |
+
summary = (
|
| 551 |
+
"AI-Human Spectrum Analysis: Insufficient text for analysis. "
|
| 552 |
+
"Risk level: LOW."
|
| 553 |
+
),
|
| 554 |
+
flags = [],
|
| 555 |
+
flags_count = 0,
|
| 556 |
+
)
|
src/scipeerai/modules/citation_analyzer.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Citation Integrity Analyzer
|
| 2 |
+
# ---------------------------
|
| 3 |
+
# Citations are the backbone of science.
|
| 4 |
+
# When they are manipulated β through self-citation
|
| 5 |
+
# abuse, retracted sources, or citation cartels β
|
| 6 |
+
# the entire knowledge chain gets corrupted.
|
| 7 |
+
#
|
| 8 |
+
# This module audits citation patterns in paper text
|
| 9 |
+
# and checks references against retraction databases.
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import json
|
| 13 |
+
import urllib.request
|
| 14 |
+
import urllib.error
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ββ data structures βββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class CitationFlag:
|
| 22 |
+
flag_type: str
|
| 23 |
+
severity: str
|
| 24 |
+
description: str
|
| 25 |
+
evidence: str
|
| 26 |
+
suggestion: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class CitationResult:
|
| 31 |
+
total_citations: int
|
| 32 |
+
self_citations: int
|
| 33 |
+
self_citation_ratio: float
|
| 34 |
+
unsupported_claims: int
|
| 35 |
+
flags: list
|
| 36 |
+
risk_score: float
|
| 37 |
+
risk_level: str
|
| 38 |
+
summary: str
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
|
| 43 |
+
class CitationAnalyzer:
|
| 44 |
+
"""
|
| 45 |
+
Two-layer citation analysis:
|
| 46 |
+
|
| 47 |
+
Layer 1 β Pattern analysis: self-citation ratio,
|
| 48 |
+
unsupported claims, citation density problems.
|
| 49 |
+
|
| 50 |
+
Layer 2 β External validation: checks author names
|
| 51 |
+
against Semantic Scholar for retraction signals.
|
| 52 |
+
Free API β no key required for basic usage.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
# ratio above this is suspicious self-citation
|
| 56 |
+
SELF_CITE_THRESHOLD = 0.30
|
| 57 |
+
|
| 58 |
+
# claims that need citations but often don't have them
|
| 59 |
+
CLAIM_MARKERS = [
|
| 60 |
+
"studies show", "research shows", "evidence suggests",
|
| 61 |
+
"it is well known", "it has been shown", "it is established",
|
| 62 |
+
"previous work shows", "literature suggests",
|
| 63 |
+
"experts agree", "scientists believe"
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
def __init__(self):
|
| 67 |
+
self._semantic_scholar_url = (
|
| 68 |
+
"https://api.semanticscholar.org/graph/v1/paper/search"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# ββ public method βββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
|
| 73 |
+
def analyze(self, text: str, author_name: str = "") -> CitationResult:
|
| 74 |
+
"""
|
| 75 |
+
Full citation integrity analysis.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
text: Full paper text
|
| 79 |
+
author_name: Primary author β used for self-citation detection
|
| 80 |
+
"""
|
| 81 |
+
citations = self._extract_citations(text)
|
| 82 |
+
self_cites = self._count_self_citations(text, author_name)
|
| 83 |
+
unsupported = self._find_unsupported_claims(text)
|
| 84 |
+
|
| 85 |
+
total = len(citations)
|
| 86 |
+
self_ratio = (self_cites / total) if total > 0 else 0.0
|
| 87 |
+
|
| 88 |
+
flags = []
|
| 89 |
+
flags.extend(self._check_self_citation_ratio(
|
| 90 |
+
self_cites, total, self_ratio
|
| 91 |
+
))
|
| 92 |
+
flags.extend(self._check_unsupported_claims(unsupported))
|
| 93 |
+
flags.extend(self._check_citation_density(text, total))
|
| 94 |
+
flags.extend(self._check_citation_patterns(text, citations))
|
| 95 |
+
|
| 96 |
+
# try live retraction check β graceful fallback
|
| 97 |
+
if author_name:
|
| 98 |
+
retraction_flags = self._check_retraction_signals(
|
| 99 |
+
citations, author_name
|
| 100 |
+
)
|
| 101 |
+
flags.extend(retraction_flags)
|
| 102 |
+
|
| 103 |
+
risk_score = self._calculate_risk(flags)
|
| 104 |
+
risk_level = self._get_risk_level(risk_score)
|
| 105 |
+
|
| 106 |
+
return CitationResult(
|
| 107 |
+
total_citations=total,
|
| 108 |
+
self_citations=self_cites,
|
| 109 |
+
self_citation_ratio=round(self_ratio, 3),
|
| 110 |
+
unsupported_claims=len(unsupported),
|
| 111 |
+
flags=flags,
|
| 112 |
+
risk_score=round(risk_score, 3),
|
| 113 |
+
risk_level=risk_level,
|
| 114 |
+
summary=self._write_summary(flags, risk_level, total),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# ββ extraction helpers ββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
|
| 119 |
+
# PATCH ONLY β replace _extract_citations method in citation_analyzer.py
|
| 120 |
+
|
| 121 |
+
def _extract_citations(self, text: str) -> list:
|
| 122 |
+
"""
|
| 123 |
+
v2.3.2 β Extract citation markers from text.
|
| 124 |
+
Handles: [1], [1,2], [1-3], (Smith, 2020), (Smith et al., 2019),
|
| 125 |
+
and plain format: Smith 2020, Jones et al. 2019 (no brackets).
|
| 126 |
+
"""
|
| 127 |
+
patterns = [
|
| 128 |
+
r'\[\d+(?:,\s*\d+)*\]', # [1] or [1,2,3]
|
| 129 |
+
r'\[\d+\-\d+\]', # [1-3]
|
| 130 |
+
r'\([A-Z][a-z]+(?:\s+et\s+al\.?)?,?\s+\d{4}\)', # (Smith, 2020)
|
| 131 |
+
r'\([A-Z][a-z]+\s+&\s+[A-Z][a-z]+,?\s+\d{4}\)', # (Smith & Jones, 2020)
|
| 132 |
+
r'\b[A-Z][a-z]+(?:\s+et\s+al\.?)?\s+\d{4}\b', # Smith 2020 (no brackets)
|
| 133 |
+
]
|
| 134 |
+
citations = []
|
| 135 |
+
for pattern in patterns:
|
| 136 |
+
found = re.findall(pattern, text)
|
| 137 |
+
citations.extend(found)
|
| 138 |
+
|
| 139 |
+
# deduplicate while preserving order
|
| 140 |
+
seen = set()
|
| 141 |
+
unique = []
|
| 142 |
+
for c in citations:
|
| 143 |
+
c_norm = re.sub(r'\s+', ' ', c.strip().lower())
|
| 144 |
+
if c_norm not in seen:
|
| 145 |
+
seen.add(c_norm)
|
| 146 |
+
unique.append(c)
|
| 147 |
+
return unique
|
| 148 |
+
|
| 149 |
+
def _count_self_citations(self, text: str, author_name: str) -> int:
|
| 150 |
+
"""
|
| 151 |
+
Count how many times the author's own name appears
|
| 152 |
+
in citation context. Checks both surname variants.
|
| 153 |
+
"""
|
| 154 |
+
if not author_name:
|
| 155 |
+
return 0
|
| 156 |
+
|
| 157 |
+
# extract surname β "Sameer Nadeem" β "Nadeem"
|
| 158 |
+
parts = author_name.strip().split()
|
| 159 |
+
surname = parts[-1] if parts else author_name
|
| 160 |
+
|
| 161 |
+
# look for surname near citation patterns
|
| 162 |
+
citation_context = re.findall(
|
| 163 |
+
rf'{re.escape(surname)}[,\s]{{0,10}}(?:\d{{4}}|et al)',
|
| 164 |
+
text,
|
| 165 |
+
re.IGNORECASE
|
| 166 |
+
)
|
| 167 |
+
return len(citation_context)
|
| 168 |
+
|
| 169 |
+
def _find_unsupported_claims(self, text: str) -> list:
|
| 170 |
+
"""
|
| 171 |
+
Find sentences that make broad claims without
|
| 172 |
+
a citation immediately following.
|
| 173 |
+
"Studies show that X" with no [1] or (Author, year) nearby.
|
| 174 |
+
"""
|
| 175 |
+
unsupported = []
|
| 176 |
+
sentences = re.split(r'[.!?]', text)
|
| 177 |
+
|
| 178 |
+
for sentence in sentences:
|
| 179 |
+
sentence = sentence.strip()
|
| 180 |
+
if len(sentence) < 15:
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
s_lower = sentence.lower()
|
| 184 |
+
has_claim_marker = any(
|
| 185 |
+
marker in s_lower for marker in self.CLAIM_MARKERS
|
| 186 |
+
)
|
| 187 |
+
has_citation = bool(re.search(
|
| 188 |
+
r'\[\d+\]|\([A-Z][a-z]+.*?\d{4}\)', sentence
|
| 189 |
+
))
|
| 190 |
+
|
| 191 |
+
if has_claim_marker and not has_citation:
|
| 192 |
+
unsupported.append(sentence)
|
| 193 |
+
|
| 194 |
+
return unsupported[:5] # cap at 5 for report clarity
|
| 195 |
+
|
| 196 |
+
# ββ flag checks βββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
+
|
| 198 |
+
def _check_self_citation_ratio(
|
| 199 |
+
self, self_cites: int, total: int, ratio: float
|
| 200 |
+
) -> list:
|
| 201 |
+
"""
|
| 202 |
+
High self-citation ratio inflates the author's
|
| 203 |
+
citation metrics without adding scientific value.
|
| 204 |
+
"""
|
| 205 |
+
flags = []
|
| 206 |
+
|
| 207 |
+
if total < 5:
|
| 208 |
+
return flags # too few citations to judge pattern
|
| 209 |
+
|
| 210 |
+
if ratio >= 0.5:
|
| 211 |
+
flags.append(CitationFlag(
|
| 212 |
+
flag_type="excessive_self_citation",
|
| 213 |
+
severity="high",
|
| 214 |
+
description=(
|
| 215 |
+
f"{self_cites} out of {total} citations "
|
| 216 |
+
f"({round(ratio*100)}%) appear to be self-citations. "
|
| 217 |
+
f"Threshold: {round(self.SELF_CITE_THRESHOLD*100)}%."
|
| 218 |
+
),
|
| 219 |
+
evidence=f"Self-citation ratio: {round(ratio, 3)}",
|
| 220 |
+
suggestion=(
|
| 221 |
+
"Review whether all self-citations are necessary. "
|
| 222 |
+
"Journals typically flag ratios above 20-30%."
|
| 223 |
+
),
|
| 224 |
+
))
|
| 225 |
+
elif ratio >= self.SELF_CITE_THRESHOLD:
|
| 226 |
+
flags.append(CitationFlag(
|
| 227 |
+
flag_type="high_self_citation_ratio",
|
| 228 |
+
severity="medium",
|
| 229 |
+
description=(
|
| 230 |
+
f"Self-citation ratio of {round(ratio*100)}% "
|
| 231 |
+
f"is above the recommended threshold."
|
| 232 |
+
),
|
| 233 |
+
evidence=f"Self-citation ratio: {round(ratio, 3)}",
|
| 234 |
+
suggestion=(
|
| 235 |
+
"Consider whether additional independent sources "
|
| 236 |
+
"could support the same claims."
|
| 237 |
+
),
|
| 238 |
+
))
|
| 239 |
+
|
| 240 |
+
return flags
|
| 241 |
+
|
| 242 |
+
def _check_unsupported_claims(self, unsupported: list) -> list:
|
| 243 |
+
"""Flag broad claims that lack any citation support."""
|
| 244 |
+
flags = []
|
| 245 |
+
|
| 246 |
+
if len(unsupported) >= 3:
|
| 247 |
+
flags.append(CitationFlag(
|
| 248 |
+
flag_type="unsupported_broad_claims",
|
| 249 |
+
severity="high",
|
| 250 |
+
description=(
|
| 251 |
+
f"{len(unsupported)} broad claim(s) found without "
|
| 252 |
+
f"supporting citations. These cannot be independently verified."
|
| 253 |
+
),
|
| 254 |
+
evidence=" | ".join(unsupported[:2]),
|
| 255 |
+
suggestion=(
|
| 256 |
+
"Each claim beginning with 'studies show' or "
|
| 257 |
+
"'it is well known' must be backed by specific citations."
|
| 258 |
+
),
|
| 259 |
+
))
|
| 260 |
+
elif len(unsupported) >= 1:
|
| 261 |
+
flags.append(CitationFlag(
|
| 262 |
+
flag_type="unsupported_claims",
|
| 263 |
+
severity="medium",
|
| 264 |
+
description=(
|
| 265 |
+
f"{len(unsupported)} claim(s) make broad assertions "
|
| 266 |
+
f"without citation support."
|
| 267 |
+
),
|
| 268 |
+
evidence=unsupported[0] if unsupported else "",
|
| 269 |
+
suggestion="Add specific citations for each broad claim.",
|
| 270 |
+
))
|
| 271 |
+
|
| 272 |
+
return flags
|
| 273 |
+
|
| 274 |
+
def _check_citation_density(self, text: str, total: int) -> list:
|
| 275 |
+
"""
|
| 276 |
+
Very few citations in a long paper = claims without backing.
|
| 277 |
+
Very many in a short paper = padding.
|
| 278 |
+
"""
|
| 279 |
+
flags = []
|
| 280 |
+
words = len(text.split())
|
| 281 |
+
# rough pages estimate
|
| 282 |
+
pages = max(1, words // 250)
|
| 283 |
+
density = total / pages
|
| 284 |
+
|
| 285 |
+
if pages >= 5 and density < 1.5:
|
| 286 |
+
flags.append(CitationFlag(
|
| 287 |
+
flag_type="low_citation_density",
|
| 288 |
+
severity="medium",
|
| 289 |
+
description=(
|
| 290 |
+
f"Only {total} citations across approximately "
|
| 291 |
+
f"{pages} pages (density: {round(density, 1)}/page). "
|
| 292 |
+
f"Well-supported papers typically cite 3-5 sources per page."
|
| 293 |
+
),
|
| 294 |
+
evidence=f"{total} total citations, ~{pages} pages",
|
| 295 |
+
suggestion=(
|
| 296 |
+
"Review whether all major claims have adequate "
|
| 297 |
+
"citation support from independent sources."
|
| 298 |
+
),
|
| 299 |
+
))
|
| 300 |
+
|
| 301 |
+
return flags
|
| 302 |
+
|
| 303 |
+
def _check_citation_patterns(self, text: str, citations: list) -> list:
|
| 304 |
+
"""
|
| 305 |
+
Detect suspicious citation clustering β
|
| 306 |
+
all citations in one section, none in others.
|
| 307 |
+
Also detects 'et al.' overuse which hides
|
| 308 |
+
the actual authors being cited.
|
| 309 |
+
"""
|
| 310 |
+
flags = []
|
| 311 |
+
|
| 312 |
+
# et al. overuse β hides who is actually being cited
|
| 313 |
+
et_al_count = len(re.findall(r'et al\.?', text, re.IGNORECASE))
|
| 314 |
+
if citations and et_al_count > 0:
|
| 315 |
+
et_al_ratio = et_al_count / max(len(citations), 1)
|
| 316 |
+
if et_al_ratio > 0.7 and len(citations) > 5:
|
| 317 |
+
flags.append(CitationFlag(
|
| 318 |
+
flag_type="et_al_overuse",
|
| 319 |
+
severity="low",
|
| 320 |
+
description=(
|
| 321 |
+
f"{et_al_count} out of {len(citations)} citations "
|
| 322 |
+
f"use 'et al.' ({round(et_al_ratio*100)}%). "
|
| 323 |
+
f"This obscures the actual authorship of cited works."
|
| 324 |
+
),
|
| 325 |
+
evidence=f"et al. ratio: {round(et_al_ratio, 2)}",
|
| 326 |
+
suggestion=(
|
| 327 |
+
"For papers with 3 or fewer authors, "
|
| 328 |
+
"list all names. Reserve et al. for 4+ authors."
|
| 329 |
+
),
|
| 330 |
+
))
|
| 331 |
+
|
| 332 |
+
return flags
|
| 333 |
+
|
| 334 |
+
def _check_retraction_signals(
|
| 335 |
+
self, citations: list, author_name: str
|
| 336 |
+
) -> list:
|
| 337 |
+
"""
|
| 338 |
+
Query Semantic Scholar for the author's papers.
|
| 339 |
+
Flag if any cited paper appears to have integrity issues.
|
| 340 |
+
This is a lightweight signal β not a definitive retraction check.
|
| 341 |
+
Full retraction database integration is a roadmap item.
|
| 342 |
+
"""
|
| 343 |
+
flags = []
|
| 344 |
+
if not author_name or not citations:
|
| 345 |
+
return flags
|
| 346 |
+
|
| 347 |
+
try:
|
| 348 |
+
surname = author_name.strip().split()[-1]
|
| 349 |
+
query = urllib.parse.quote(surname)
|
| 350 |
+
url = (
|
| 351 |
+
f"{self._semantic_scholar_url}"
|
| 352 |
+
f"?query={query}&fields=title,year,authors&limit=5"
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
req = urllib.request.Request(
|
| 356 |
+
url,
|
| 357 |
+
headers={"User-Agent": "SciPeerAI/0.1 Research Tool"}
|
| 358 |
+
)
|
| 359 |
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
| 360 |
+
data = json.loads(resp.read().decode())
|
| 361 |
+
|
| 362 |
+
# if we get results β API is live β note it worked
|
| 363 |
+
# full retraction checking needs Retraction Watch API
|
| 364 |
+
# which requires institutional access
|
| 365 |
+
if data.get("data"):
|
| 366 |
+
pass # API working β retraction DB integration: Phase D
|
| 367 |
+
|
| 368 |
+
except Exception:
|
| 369 |
+
pass # external API down β silent fail, not critical
|
| 370 |
+
|
| 371 |
+
return flags
|
| 372 |
+
|
| 373 |
+
# ββ scoring βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 374 |
+
|
| 375 |
+
def _calculate_risk(self, flags: list) -> float:
|
| 376 |
+
weights = {"high": 0.35, "medium": 0.20, "low": 0.08}
|
| 377 |
+
score = sum(weights.get(f.severity, 0) for f in flags)
|
| 378 |
+
return min(score, 1.0)
|
| 379 |
+
|
| 380 |
+
def _get_risk_level(self, score: float) -> str:
|
| 381 |
+
if score >= 0.7: return "critical"
|
| 382 |
+
elif score >= 0.4: return "high"
|
| 383 |
+
elif score >= 0.2: return "medium"
|
| 384 |
+
return "low"
|
| 385 |
+
|
| 386 |
+
def _write_summary(
|
| 387 |
+
self, flags: list, risk_level: str, total: int
|
| 388 |
+
) -> str:
|
| 389 |
+
if not flags:
|
| 390 |
+
return (
|
| 391 |
+
f"Analyzed {total} citation(s). "
|
| 392 |
+
f"No citation integrity issues detected."
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 396 |
+
med = sum(1 for f in flags if f.severity == "medium")
|
| 397 |
+
parts = []
|
| 398 |
+
if high: parts.append(
|
| 399 |
+
f"{high} high-severity issue{'s' if high > 1 else ''}"
|
| 400 |
+
)
|
| 401 |
+
if med: parts.append(
|
| 402 |
+
f"{med} medium-severity concern{'s' if med > 1 else ''}"
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
return (
|
| 406 |
+
f"Analyzed {total} citation(s). "
|
| 407 |
+
f"Citation audit flagged {', '.join(parts)}. "
|
| 408 |
+
f"Risk level: {risk_level.upper()}."
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# ββ fix missing import ββββββββββββββββββββββββββββββββββββββββ
|
| 413 |
+
import urllib.parse
|
src/scipeerai/modules/citation_cartel.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ο»Ώ# src/scipeerai/modules/citation_cartel.py
|
| 2 |
+
#
|
| 3 |
+
# Citation Cartel Detector
|
| 4 |
+
# Detects suspicious citation network patterns:
|
| 5 |
+
# - Authors citing only each other (cartels)
|
| 6 |
+
# - Excessive self-citation clusters
|
| 7 |
+
# - Citation ring patterns
|
| 8 |
+
# - Suspiciously narrow citation networks
|
| 9 |
+
#
|
| 10 |
+
# Based on network analysis techniques used in
|
| 11 |
+
# Nature/Science level integrity research.
|
| 12 |
+
# Nobody implements this freely.
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
import math
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class CartelFlag:
|
| 22 |
+
flag_type: str
|
| 23 |
+
severity: str
|
| 24 |
+
description: str
|
| 25 |
+
evidence: str
|
| 26 |
+
suggestion: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class CartelResult:
|
| 31 |
+
authors_found: list
|
| 32 |
+
citation_network: dict
|
| 33 |
+
cartel_score: float
|
| 34 |
+
self_citation_ratio: float
|
| 35 |
+
network_diversity: float
|
| 36 |
+
risk_level: str
|
| 37 |
+
summary: str
|
| 38 |
+
flags: list = field(default_factory=list)
|
| 39 |
+
flags_count: int = 0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class CitationCartelDetector:
|
| 43 |
+
"""
|
| 44 |
+
Citation Cartel Detector.
|
| 45 |
+
Analyzes citation patterns to detect cartels Γ’β¬β
|
| 46 |
+
groups of authors who exclusively cite each other
|
| 47 |
+
to artificially inflate impact metrics.
|
| 48 |
+
|
| 49 |
+
Key signals:
|
| 50 |
+
- High ratio of citations to same small group
|
| 51 |
+
- Same author names appearing repeatedly
|
| 52 |
+
- Narrow institutional citation network
|
| 53 |
+
- Reciprocal citation patterns
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
# Author name patterns in references
|
| 57 |
+
AUTHOR_PAT = re.compile(
|
| 58 |
+
r'([A-Z][a-z]+(?:\s+[A-Z][a-z]*\.?)+)',
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
CITE_PAT = re.compile(
|
| 62 |
+
r'([A-Z][a-z]+(?:\s+et\s+al\.?)?)\s*[\(\[,]?\s*(\d{4})[\)\]]?',
|
| 63 |
+
re.IGNORECASE
|
| 64 |
+
)
|
| 65 |
+
CITE_PAT_PLAIN = re.compile(
|
| 66 |
+
r'([A-Z][a-z]+(?:\s+et\s+al\.?)?)\s+(\d{4})(?:\b)',
|
| 67 |
+
re.IGNORECASE
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# Et al pattern
|
| 72 |
+
ETAL_PAT = re.compile(
|
| 73 |
+
r'([A-Z][a-z]+)\s+et\s+al\.?\s*[\(\[,]?\s*(\d{4})',
|
| 74 |
+
re.IGNORECASE
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Reference section markers
|
| 78 |
+
REF_MARKERS = re.compile(
|
| 79 |
+
r'\b(?:references|bibliography|works\s+cited)\b',
|
| 80 |
+
re.IGNORECASE
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
def analyze(self, text: str) -> CartelResult:
|
| 84 |
+
citations = self._extract_citations(text)
|
| 85 |
+
authors = self._extract_authors(citations)
|
| 86 |
+
network = self._build_network(citations)
|
| 87 |
+
flags = []
|
| 88 |
+
|
| 89 |
+
# Γ’ββ¬Γ’ββ¬ Analysis Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 90 |
+
self_cite_ratio = self._self_citation_ratio(citations, text)
|
| 91 |
+
diversity = self._network_diversity(network, citations)
|
| 92 |
+
concentration = self._concentration_score(network, citations)
|
| 93 |
+
reciprocal = self._detect_reciprocal(network)
|
| 94 |
+
|
| 95 |
+
# Γ’ββ¬Γ’ββ¬ Flag 1: High concentration Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 96 |
+
if concentration > 0.5 and len(citations) >= 5:
|
| 97 |
+
top_author = max(network, key=lambda a: network[a],
|
| 98 |
+
default=None)
|
| 99 |
+
top_count = network.get(top_author, 0)
|
| 100 |
+
flags.append(CartelFlag(
|
| 101 |
+
flag_type = "citation_concentration",
|
| 102 |
+
severity = "high" if concentration > 0.7 else "medium",
|
| 103 |
+
description = (
|
| 104 |
+
f"Citation network is highly concentrated Γ’β¬β "
|
| 105 |
+
f"{round(concentration*100)}% of citations point "
|
| 106 |
+
f"to a small group of authors. This pattern "
|
| 107 |
+
f"suggests a citation cartel or echo chamber "
|
| 108 |
+
f"rather than broad scholarly engagement."
|
| 109 |
+
),
|
| 110 |
+
evidence = (
|
| 111 |
+
f"Top cited author: '{top_author}' "
|
| 112 |
+
f"({top_count} citations) | "
|
| 113 |
+
f"Concentration score: {round(concentration*100)}% | "
|
| 114 |
+
f"Total citations analyzed: {len(citations)}"
|
| 115 |
+
),
|
| 116 |
+
suggestion = (
|
| 117 |
+
"Broaden citation network to include diverse "
|
| 118 |
+
"sources. Avoid citing only colleagues or "
|
| 119 |
+
"institutional partners. Aim for <30% citations "
|
| 120 |
+
"from any single research group."
|
| 121 |
+
),
|
| 122 |
+
))
|
| 123 |
+
|
| 124 |
+
# Γ’ββ¬Γ’ββ¬ Flag 2: Low network diversity Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 125 |
+
if diversity < 0.3 and len(citations) >= 8:
|
| 126 |
+
flags.append(CartelFlag(
|
| 127 |
+
flag_type = "low_citation_diversity",
|
| 128 |
+
severity = "medium",
|
| 129 |
+
description = (
|
| 130 |
+
f"Citation network diversity score: "
|
| 131 |
+
f"{round(diversity*100)}%. "
|
| 132 |
+
f"Paper draws from a very narrow pool of authors. "
|
| 133 |
+
f"Genuine scholarship typically cites a broad "
|
| 134 |
+
f"range of researchers across institutions."
|
| 135 |
+
),
|
| 136 |
+
evidence = (
|
| 137 |
+
f"Unique authors cited: {len(network)} | "
|
| 138 |
+
f"Total citations: {len(citations)} | "
|
| 139 |
+
f"Diversity score: {round(diversity*100)}%"
|
| 140 |
+
),
|
| 141 |
+
suggestion = (
|
| 142 |
+
"Include citations from diverse research groups, "
|
| 143 |
+
"institutions, and geographic regions. "
|
| 144 |
+
"Review recent literature more broadly."
|
| 145 |
+
),
|
| 146 |
+
))
|
| 147 |
+
|
| 148 |
+
# Γ’ββ¬Γ’ββ¬ Flag 3: Excessive self-citation Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 149 |
+
if self_cite_ratio > 0.3 and len(citations) >= 5:
|
| 150 |
+
flags.append(CartelFlag(
|
| 151 |
+
flag_type = "excessive_self_citation",
|
| 152 |
+
severity = "high" if self_cite_ratio > 0.5 else "medium",
|
| 153 |
+
description = (
|
| 154 |
+
f"Self-citation ratio: {round(self_cite_ratio*100)}%. "
|
| 155 |
+
f"Paper cites the same author(s) excessively Γ’β¬β "
|
| 156 |
+
f"threshold for concern is >30%. This inflates "
|
| 157 |
+
f"citation metrics artificially."
|
| 158 |
+
),
|
| 159 |
+
evidence = (
|
| 160 |
+
f"Self-citation rate: {round(self_cite_ratio*100)}% "
|
| 161 |
+
f"(acceptable: <20%) | "
|
| 162 |
+
f"Citations analyzed: {len(citations)}"
|
| 163 |
+
),
|
| 164 |
+
suggestion = (
|
| 165 |
+
"Limit self-citations to genuinely necessary "
|
| 166 |
+
"references. Most journals recommend <20% "
|
| 167 |
+
"self-citation rate."
|
| 168 |
+
),
|
| 169 |
+
))
|
| 170 |
+
|
| 171 |
+
# Γ’ββ¬Γ’ββ¬ Flag 4: Reciprocal citation pattern Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 172 |
+
if reciprocal:
|
| 173 |
+
flags.append(CartelFlag(
|
| 174 |
+
flag_type = "reciprocal_citation_pattern",
|
| 175 |
+
severity = "medium",
|
| 176 |
+
description = (
|
| 177 |
+
f"Potential reciprocal citation pattern detected. "
|
| 178 |
+
f"Authors: {', '.join(reciprocal[:3])} appear "
|
| 179 |
+
f"multiple times in concentrated citation clusters. "
|
| 180 |
+
f"This may indicate coordinated citation exchange."
|
| 181 |
+
),
|
| 182 |
+
evidence = (
|
| 183 |
+
f"Repeated author clusters: "
|
| 184 |
+
f"{', '.join(reciprocal[:5])}"
|
| 185 |
+
),
|
| 186 |
+
suggestion = (
|
| 187 |
+
"Ensure citations reflect genuine intellectual "
|
| 188 |
+
"debt rather than reciprocal agreements. "
|
| 189 |
+
"Disclose any collaborative relationships."
|
| 190 |
+
),
|
| 191 |
+
))
|
| 192 |
+
|
| 193 |
+
# Γ’ββ¬Γ’ββ¬ Flag 5: Too few citations Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 194 |
+
if len(citations) < 5:
|
| 195 |
+
flags.append(CartelFlag(
|
| 196 |
+
flag_type = "insufficient_citations",
|
| 197 |
+
severity = "low",
|
| 198 |
+
description = (
|
| 199 |
+
f"Only {len(citations)} citation(s) detected. "
|
| 200 |
+
f"Citation cartel analysis requires at least "
|
| 201 |
+
f"5 citations. Paste full references section "
|
| 202 |
+
f"for complete network analysis."
|
| 203 |
+
),
|
| 204 |
+
evidence = f"{len(citations)} citations found",
|
| 205 |
+
suggestion = (
|
| 206 |
+
"Include full references section for "
|
| 207 |
+
"complete citation network analysis."
|
| 208 |
+
),
|
| 209 |
+
))
|
| 210 |
+
|
| 211 |
+
score = self._aggregate_score(
|
| 212 |
+
concentration, diversity, self_cite_ratio, citations
|
| 213 |
+
)
|
| 214 |
+
level = self._risk(score, len(flags))
|
| 215 |
+
summary = self._build_summary(
|
| 216 |
+
citations, network, score, level,
|
| 217 |
+
concentration, diversity
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
return CartelResult(
|
| 221 |
+
authors_found = list(network.keys())[:10],
|
| 222 |
+
citation_network = dict(network),
|
| 223 |
+
cartel_score = round(score, 4),
|
| 224 |
+
self_citation_ratio = round(self_cite_ratio, 4),
|
| 225 |
+
network_diversity = round(diversity, 4),
|
| 226 |
+
risk_level = level,
|
| 227 |
+
summary = summary,
|
| 228 |
+
flags = flags,
|
| 229 |
+
flags_count = len(flags),
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Γ’ββ¬Γ’ββ¬ internal helpers Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 233 |
+
|
| 234 |
+
def _extract_citations(self, text: str) -> list:
|
| 235 |
+
citations = []
|
| 236 |
+
seen = set()
|
| 237 |
+
for m in self.CITE_PAT.finditer(text):
|
| 238 |
+
key = (m.group(1).strip().lower(), m.group(2))
|
| 239 |
+
if key not in seen:
|
| 240 |
+
seen.add(key)
|
| 241 |
+
citations.append((m.group(1).strip(), m.group(2)))
|
| 242 |
+
for m in self.ETAL_PAT.finditer(text):
|
| 243 |
+
key = (m.group(1).strip().lower(), m.group(2))
|
| 244 |
+
if key not in seen:
|
| 245 |
+
seen.add(key)
|
| 246 |
+
citations.append((m.group(1).strip(), m.group(2)))
|
| 247 |
+
for m in self.CITE_PAT_PLAIN.finditer(text):
|
| 248 |
+
key = (m.group(1).strip().lower(), m.group(2))
|
| 249 |
+
if key not in seen:
|
| 250 |
+
seen.add(key)
|
| 251 |
+
citations.append((m.group(1).strip(), m.group(2)))
|
| 252 |
+
return citations
|
| 253 |
+
|
| 254 |
+
def _extract_authors(self, citations: list) -> list:
|
| 255 |
+
return list(set(c[0] for c in citations))
|
| 256 |
+
|
| 257 |
+
def _build_network(self, citations: list) -> dict:
|
| 258 |
+
network = defaultdict(int)
|
| 259 |
+
for author, _ in citations:
|
| 260 |
+
network[author] += 1
|
| 261 |
+
return dict(network)
|
| 262 |
+
|
| 263 |
+
def _self_citation_ratio(self, citations: list,
|
| 264 |
+
text: str) -> float:
|
| 265 |
+
if not citations:
|
| 266 |
+
return 0.0
|
| 267 |
+
# Find most frequent author
|
| 268 |
+
network = self._build_network(citations)
|
| 269 |
+
if not network:
|
| 270 |
+
return 0.0
|
| 271 |
+
top_count = max(network.values())
|
| 272 |
+
return min(top_count / len(citations), 1.0)
|
| 273 |
+
|
| 274 |
+
def _network_diversity(self, network: dict,
|
| 275 |
+
citations: list) -> float:
|
| 276 |
+
if not citations:
|
| 277 |
+
return 1.0
|
| 278 |
+
unique_authors = len(network)
|
| 279 |
+
total_cites = len(citations)
|
| 280 |
+
if total_cites == 0:
|
| 281 |
+
return 1.0
|
| 282 |
+
# Shannon entropy normalized
|
| 283 |
+
entropy = 0.0
|
| 284 |
+
for count in network.values():
|
| 285 |
+
p = count / total_cites
|
| 286 |
+
if p > 0:
|
| 287 |
+
entropy -= p * math.log2(p)
|
| 288 |
+
max_entropy = math.log2(max(unique_authors, 1))
|
| 289 |
+
return (entropy / max_entropy) if max_entropy > 0 else 1.0
|
| 290 |
+
|
| 291 |
+
def _concentration_score(self, network: dict,
|
| 292 |
+
citations: list) -> float:
|
| 293 |
+
if not citations or not network:
|
| 294 |
+
return 0.0
|
| 295 |
+
total = len(citations)
|
| 296 |
+
# Top 3 authors' share
|
| 297 |
+
top3 = sorted(network.values(), reverse=True)[:3]
|
| 298 |
+
return sum(top3) / total
|
| 299 |
+
|
| 300 |
+
def _detect_reciprocal(self, network: dict) -> list:
|
| 301 |
+
if not network:
|
| 302 |
+
return []
|
| 303 |
+
avg = sum(network.values()) / len(network)
|
| 304 |
+
return [a for a, c in network.items() if c >= avg * 2]
|
| 305 |
+
|
| 306 |
+
def _aggregate_score(self, concentration: float,
|
| 307 |
+
diversity: float,
|
| 308 |
+
self_cite: float,
|
| 309 |
+
citations: list) -> float:
|
| 310 |
+
if len(citations) < 3:
|
| 311 |
+
return 0.0
|
| 312 |
+
score = (
|
| 313 |
+
concentration * 0.4 +
|
| 314 |
+
(1 - diversity) * 0.3 +
|
| 315 |
+
self_cite * 0.3
|
| 316 |
+
)
|
| 317 |
+
return min(round(score, 4), 1.0)
|
| 318 |
+
|
| 319 |
+
def _risk(self, score: float, flag_count: int) -> str:
|
| 320 |
+
if score >= 0.6 or flag_count >= 3:
|
| 321 |
+
return "critical"
|
| 322 |
+
if score >= 0.4 or flag_count >= 2:
|
| 323 |
+
return "high"
|
| 324 |
+
if score >= 0.2 or flag_count >= 1:
|
| 325 |
+
return "medium"
|
| 326 |
+
return "low"
|
| 327 |
+
|
| 328 |
+
def _build_summary(self, citations, network, score,
|
| 329 |
+
level, concentration, diversity) -> str:
|
| 330 |
+
if len(citations) < 3:
|
| 331 |
+
return (
|
| 332 |
+
"Citation Cartel Analysis: Insufficient citations "
|
| 333 |
+
"detected. Paste full references section for "
|
| 334 |
+
"network analysis. Risk level: LOW."
|
| 335 |
+
)
|
| 336 |
+
pct = round(score * 100)
|
| 337 |
+
return (
|
| 338 |
+
f"Citation network analyzed: {len(citations)} citations, "
|
| 339 |
+
f"{len(network)} unique authors. "
|
| 340 |
+
f"Concentration: {round(concentration*100)}%, "
|
| 341 |
+
f"Diversity: {round(diversity*100)}%. "
|
| 342 |
+
f"Cartel risk score: {pct}%. "
|
| 343 |
+
f"Risk level: {level.upper()}."
|
| 344 |
+
)
|
src/scipeerai/modules/citation_dna.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Citation DNA Analysis
|
| 2 |
+
# ---------------------
|
| 3 |
+
# Every researcher leaves a citation fingerprint.
|
| 4 |
+
# The journals they trust. The authors they follow.
|
| 5 |
+
# The countries they draw from. The decade they anchor to.
|
| 6 |
+
#
|
| 7 |
+
# Two genuinely independent papers on the same topic
|
| 8 |
+
# will share some citations β but not 80% of them.
|
| 9 |
+
# When citation overlap is that high, the papers are not
|
| 10 |
+
# independent. They are the same work in a different coat.
|
| 11 |
+
#
|
| 12 |
+
# This module extracts the citation DNA of a paper:
|
| 13 |
+
# the network of who is cited, how often, from where,
|
| 14 |
+
# and how diverse that network is.
|
| 15 |
+
# Then it measures the health of that network.
|
| 16 |
+
#
|
| 17 |
+
# A manipulated reference list has patterns:
|
| 18 |
+
# too concentrated in one journal, one author, one year,
|
| 19 |
+
# one institution β or suspiciously identical to another
|
| 20 |
+
# paper the same group published six months earlier.
|
| 21 |
+
|
| 22 |
+
import re
|
| 23 |
+
import math
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from collections import Counter
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ββ constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
+
|
| 30 |
+
# major journal name fragments β used to detect journal concentration
|
| 31 |
+
KNOWN_JOURNALS = [
|
| 32 |
+
"nature", "science", "cell", "lancet", "jama", "nejm",
|
| 33 |
+
"plos", "pnas", "ieee", "acm", "springer", "elsevier",
|
| 34 |
+
"wiley", "oxford", "cambridge", "frontiers", "mdpi",
|
| 35 |
+
"journal of", "proceedings of", "transactions on",
|
| 36 |
+
"review of", "annals of", "advances in",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# country/region markers found in author affiliations and addresses
|
| 40 |
+
COUNTRY_MARKERS = [
|
| 41 |
+
"usa", "united states", "uk", "united kingdom", "china",
|
| 42 |
+
"germany", "france", "japan", "india", "australia",
|
| 43 |
+
"canada", "italy", "spain", "netherlands", "sweden",
|
| 44 |
+
"switzerland", "brazil", "south korea", "russia", "iran",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ββ data structures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class CitationNode:
|
| 52 |
+
author: str
|
| 53 |
+
year: int
|
| 54 |
+
journal: str
|
| 55 |
+
raw: str
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class DNAFlag:
|
| 60 |
+
flag_type: str
|
| 61 |
+
severity: str
|
| 62 |
+
description: str
|
| 63 |
+
evidence: str
|
| 64 |
+
suggestion: str
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class CitationDNAResult:
|
| 69 |
+
total_citations: int
|
| 70 |
+
unique_authors: int
|
| 71 |
+
year_distribution: dict
|
| 72 |
+
journal_distribution: dict
|
| 73 |
+
author_concentration: float
|
| 74 |
+
journal_concentration: float
|
| 75 |
+
decade_concentration: float
|
| 76 |
+
geographic_diversity: float
|
| 77 |
+
dna_diversity_score: float
|
| 78 |
+
dna_risk_score: float
|
| 79 |
+
risk_level: str
|
| 80 |
+
summary: str
|
| 81 |
+
flags: list
|
| 82 |
+
flags_count: int
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
|
| 87 |
+
class CitationDNAAnalyzer:
|
| 88 |
+
"""
|
| 89 |
+
Extracts and evaluates the citation network of a paper.
|
| 90 |
+
|
| 91 |
+
Four dimensions of analysis:
|
| 92 |
+
1. Author concentration β too many citations to one person
|
| 93 |
+
2. Journal concentration β too many citations from one outlet
|
| 94 |
+
3. Temporal concentration β all citations from one decade
|
| 95 |
+
4. Geographic diversity β single-country citation network
|
| 96 |
+
|
| 97 |
+
A healthy reference list is diverse across all four dimensions.
|
| 98 |
+
Concentration in any dimension is a manipulation signal.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def analyze(self, text: str) -> CitationDNAResult:
|
| 102 |
+
nodes = self._extract_citation_nodes(text)
|
| 103 |
+
flags = []
|
| 104 |
+
if not nodes:
|
| 105 |
+
return CitationDNAResult(
|
| 106 |
+
total_citations = 0,
|
| 107 |
+
unique_authors = 0,
|
| 108 |
+
year_distribution = {},
|
| 109 |
+
journal_distribution = {},
|
| 110 |
+
author_concentration = 0.0,
|
| 111 |
+
journal_concentration = 0.0,
|
| 112 |
+
decade_concentration = 0.0,
|
| 113 |
+
geographic_diversity = 0.0,
|
| 114 |
+
dna_diversity_score = 1.0,
|
| 115 |
+
dna_risk_score = 0.0,
|
| 116 |
+
risk_level = "low",
|
| 117 |
+
summary = (
|
| 118 |
+
"Citation DNA Analysis: No citations extracted. "
|
| 119 |
+
"Paste the full references section for complete "
|
| 120 |
+
"network analysis. Risk level: LOW."
|
| 121 |
+
),
|
| 122 |
+
flags = [],
|
| 123 |
+
flags_count = 0,
|
| 124 |
+
)
|
| 125 |
+
author_conc = self._measure_author_concentration(nodes, flags)
|
| 126 |
+
journal_conc = self._measure_journal_concentration(nodes, flags)
|
| 127 |
+
decade_conc = self._measure_decade_concentration(nodes, flags)
|
| 128 |
+
geo_div = self._measure_geographic_diversity(text, flags)
|
| 129 |
+
|
| 130 |
+
year_dist = self._year_distribution(nodes)
|
| 131 |
+
journal_dist = self._journal_distribution(nodes)
|
| 132 |
+
|
| 133 |
+
diversity = self._compute_diversity(
|
| 134 |
+
author_conc, journal_conc, decade_conc, geo_div
|
| 135 |
+
)
|
| 136 |
+
risk_score = 1.0 - diversity
|
| 137 |
+
risk_level = self._get_risk_level(risk_score)
|
| 138 |
+
|
| 139 |
+
return CitationDNAResult(
|
| 140 |
+
total_citations = len(nodes),
|
| 141 |
+
unique_authors = len({n.author for n in nodes if n.author}),
|
| 142 |
+
year_distribution = year_dist,
|
| 143 |
+
journal_distribution = journal_dist,
|
| 144 |
+
author_concentration = round(author_conc, 3),
|
| 145 |
+
journal_concentration = round(journal_conc, 3),
|
| 146 |
+
decade_concentration = round(decade_conc, 3),
|
| 147 |
+
geographic_diversity = round(geo_div, 3),
|
| 148 |
+
dna_diversity_score = round(diversity, 3),
|
| 149 |
+
dna_risk_score = round(risk_score, 3),
|
| 150 |
+
risk_level = risk_level,
|
| 151 |
+
summary = self._write_summary(
|
| 152 |
+
nodes, flags, risk_level, diversity
|
| 153 |
+
),
|
| 154 |
+
flags = flags,
|
| 155 |
+
flags_count = len(flags),
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# ββ citation extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
+
|
| 160 |
+
def _extract_citation_nodes(self, text: str) -> list:
|
| 161 |
+
"""
|
| 162 |
+
Parse individual citations from the paper text.
|
| 163 |
+
Handles author-year inline style and numbered reference lists.
|
| 164 |
+
Each node carries author name, year, and guessed journal.
|
| 165 |
+
"""
|
| 166 |
+
nodes = []
|
| 167 |
+
|
| 168 |
+
# inline style: Smith (2018), Jones et al. (2020)
|
| 169 |
+
for match in re.finditer(
|
| 170 |
+
r'([A-Z][a-zA-Z\-]+)(?:\s+et\s+al\.?)?\s+\((\d{4})\)',
|
| 171 |
+
text
|
| 172 |
+
):
|
| 173 |
+
author = match.group(1).lower()
|
| 174 |
+
year = self._safe_year(match.group(2))
|
| 175 |
+
if year:
|
| 176 |
+
nodes.append(CitationNode(
|
| 177 |
+
author = author,
|
| 178 |
+
year = year,
|
| 179 |
+
journal = self._guess_journal(text, match.start()),
|
| 180 |
+
raw = match.group(0),
|
| 181 |
+
))
|
| 182 |
+
|
| 183 |
+
# if no inline citations found, try reference list parsing
|
| 184 |
+
if not nodes:
|
| 185 |
+
ref_section = self._get_ref_section(text)
|
| 186 |
+
if ref_section:
|
| 187 |
+
nodes = self._parse_reference_list(ref_section)
|
| 188 |
+
|
| 189 |
+
return nodes
|
| 190 |
+
|
| 191 |
+
def _parse_reference_list(self, ref_text: str) -> list:
|
| 192 |
+
"""
|
| 193 |
+
Extract structured nodes from a formatted reference list.
|
| 194 |
+
Works on numbered lists like [1] Author, A. (Year). Title. Journal.
|
| 195 |
+
"""
|
| 196 |
+
nodes = []
|
| 197 |
+
lines = ref_text.split("\n")
|
| 198 |
+
|
| 199 |
+
for line in lines:
|
| 200 |
+
line = line.strip()
|
| 201 |
+
if len(line) < 20:
|
| 202 |
+
continue
|
| 203 |
+
year_match = re.search(r'\((\d{4})\)', line)
|
| 204 |
+
if not year_match:
|
| 205 |
+
year_match = re.search(r'\b(19[5-9]\d|20[0-2]\d)\b', line)
|
| 206 |
+
if not year_match:
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
year = self._safe_year(year_match.group(1))
|
| 210 |
+
if not year:
|
| 211 |
+
continue
|
| 212 |
+
|
| 213 |
+
# first capitalized word as author proxy
|
| 214 |
+
author_match = re.search(r'[A-Z][a-z]+', line)
|
| 215 |
+
author = author_match.group(0).lower() if author_match else "unknown"
|
| 216 |
+
|
| 217 |
+
journal = ""
|
| 218 |
+
for known in KNOWN_JOURNALS:
|
| 219 |
+
if known in line.lower():
|
| 220 |
+
journal = known
|
| 221 |
+
break
|
| 222 |
+
|
| 223 |
+
nodes.append(CitationNode(
|
| 224 |
+
author = author,
|
| 225 |
+
year = year,
|
| 226 |
+
journal = journal,
|
| 227 |
+
raw = line[:80],
|
| 228 |
+
))
|
| 229 |
+
|
| 230 |
+
return nodes
|
| 231 |
+
|
| 232 |
+
def _guess_journal(self, text: str, position: int) -> str:
|
| 233 |
+
"""
|
| 234 |
+
Look in a window around the citation for a known journal name.
|
| 235 |
+
Used for inline citations that do not have full reference data.
|
| 236 |
+
"""
|
| 237 |
+
window = text[max(0, position - 100): position + 200].lower()
|
| 238 |
+
for journal in KNOWN_JOURNALS:
|
| 239 |
+
if journal in window:
|
| 240 |
+
return journal
|
| 241 |
+
return ""
|
| 242 |
+
|
| 243 |
+
def _get_ref_section(self, text: str) -> str:
|
| 244 |
+
text_lo = text.lower()
|
| 245 |
+
for marker in ["references", "bibliography", "works cited"]:
|
| 246 |
+
idx = text_lo.rfind(marker)
|
| 247 |
+
if idx != -1:
|
| 248 |
+
return text[idx:]
|
| 249 |
+
return ""
|
| 250 |
+
|
| 251 |
+
def _safe_year(self, raw: str) -> int:
|
| 252 |
+
try:
|
| 253 |
+
year = int(raw.strip())
|
| 254 |
+
if 1900 <= year <= 2100:
|
| 255 |
+
return year
|
| 256 |
+
except (ValueError, AttributeError):
|
| 257 |
+
pass
|
| 258 |
+
return 0
|
| 259 |
+
|
| 260 |
+
# ββ concentration measures βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 261 |
+
|
| 262 |
+
def _measure_author_concentration(
|
| 263 |
+
self, nodes: list, flags: list
|
| 264 |
+
) -> float:
|
| 265 |
+
"""
|
| 266 |
+
Herfindahl-Hirschman Index on cited authors.
|
| 267 |
+
HHI near 1.0 means one author dominates the reference list.
|
| 268 |
+
A genuine literature review draws from many different voices.
|
| 269 |
+
"""
|
| 270 |
+
if not nodes:
|
| 271 |
+
return 0.0
|
| 272 |
+
|
| 273 |
+
authors = [n.author for n in nodes if n.author]
|
| 274 |
+
if not authors:
|
| 275 |
+
return 0.0
|
| 276 |
+
|
| 277 |
+
hhi = self._hhi(authors)
|
| 278 |
+
|
| 279 |
+
if hhi >= 0.25:
|
| 280 |
+
counter = Counter(authors)
|
| 281 |
+
top_name, top_count = counter.most_common(1)[0]
|
| 282 |
+
ratio = top_count / len(authors)
|
| 283 |
+
flags.append(DNAFlag(
|
| 284 |
+
flag_type = "author_concentration",
|
| 285 |
+
severity = "high" if hhi >= 0.40 else "medium",
|
| 286 |
+
description = (
|
| 287 |
+
f"Citation network is concentrated around a small number "
|
| 288 |
+
f"of authors. HHI concentration index: {round(hhi, 3)}. "
|
| 289 |
+
f"Diverse literature reviews have HHI below 0.10."
|
| 290 |
+
),
|
| 291 |
+
evidence = (
|
| 292 |
+
f"Most cited author: '{top_name}' appears in "
|
| 293 |
+
f"{top_count}/{len(authors)} citations "
|
| 294 |
+
f"({round(ratio * 100, 1)}%)."
|
| 295 |
+
),
|
| 296 |
+
suggestion = (
|
| 297 |
+
"Broaden the reference list to include a wider range "
|
| 298 |
+
"of authors. High author concentration may indicate "
|
| 299 |
+
"citation cartel behavior or incomplete literature review."
|
| 300 |
+
),
|
| 301 |
+
))
|
| 302 |
+
|
| 303 |
+
return hhi
|
| 304 |
+
|
| 305 |
+
def _measure_journal_concentration(
|
| 306 |
+
self, nodes: list, flags: list
|
| 307 |
+
) -> float:
|
| 308 |
+
"""
|
| 309 |
+
Measures how many citations come from a single journal or publisher.
|
| 310 |
+
A paper citing 80% of its sources from one journal is suspicious.
|
| 311 |
+
"""
|
| 312 |
+
if not nodes:
|
| 313 |
+
return 0.0
|
| 314 |
+
|
| 315 |
+
journals = [n.journal for n in nodes if n.journal]
|
| 316 |
+
if len(journals) < 3:
|
| 317 |
+
return 0.0
|
| 318 |
+
|
| 319 |
+
hhi = self._hhi(journals)
|
| 320 |
+
|
| 321 |
+
if hhi >= 0.30:
|
| 322 |
+
counter = Counter(journals)
|
| 323 |
+
top_j, top_count = counter.most_common(1)[0]
|
| 324 |
+
flags.append(DNAFlag(
|
| 325 |
+
flag_type = "journal_concentration",
|
| 326 |
+
severity = "medium",
|
| 327 |
+
description = (
|
| 328 |
+
f"Citations are heavily concentrated in one journal "
|
| 329 |
+
f"or publisher. Journal concentration HHI: {round(hhi, 3)}."
|
| 330 |
+
),
|
| 331 |
+
evidence = (
|
| 332 |
+
f"Most cited outlet: '{top_j}' accounts for "
|
| 333 |
+
f"{top_count}/{len(journals)} identified journal citations."
|
| 334 |
+
),
|
| 335 |
+
suggestion = (
|
| 336 |
+
"A balanced reference list draws from multiple journals "
|
| 337 |
+
"across different publishers. Single-outlet concentration "
|
| 338 |
+
"may reflect editorial bias or citation ring behavior."
|
| 339 |
+
),
|
| 340 |
+
))
|
| 341 |
+
|
| 342 |
+
return hhi
|
| 343 |
+
|
| 344 |
+
def _measure_decade_concentration(
|
| 345 |
+
self, nodes: list, flags: list
|
| 346 |
+
) -> float:
|
| 347 |
+
"""
|
| 348 |
+
Maps citations to decades and measures concentration.
|
| 349 |
+
A paper claiming to survey a field but citing only one decade
|
| 350 |
+
is presenting an incomplete β possibly cherry-picked β picture.
|
| 351 |
+
"""
|
| 352 |
+
if not nodes:
|
| 353 |
+
return 0.0
|
| 354 |
+
|
| 355 |
+
decades = [
|
| 356 |
+
f"{(n.year // 10) * 10}s"
|
| 357 |
+
for n in nodes if n.year
|
| 358 |
+
]
|
| 359 |
+
if not decades:
|
| 360 |
+
return 0.0
|
| 361 |
+
|
| 362 |
+
hhi = self._hhi(decades)
|
| 363 |
+
|
| 364 |
+
if hhi >= 0.60:
|
| 365 |
+
counter = Counter(decades)
|
| 366 |
+
top_d, top_count = counter.most_common(1)[0]
|
| 367 |
+
flags.append(DNAFlag(
|
| 368 |
+
flag_type = "temporal_concentration",
|
| 369 |
+
severity = "medium",
|
| 370 |
+
description = (
|
| 371 |
+
f"Citations cluster heavily in one decade. "
|
| 372 |
+
f"Temporal concentration HHI: {round(hhi, 3)}. "
|
| 373 |
+
f"A thorough literature review spans multiple decades."
|
| 374 |
+
),
|
| 375 |
+
evidence = (
|
| 376 |
+
f"Dominant decade: {top_d} β "
|
| 377 |
+
f"{top_count}/{len(decades)} citations "
|
| 378 |
+
f"({round(top_count/len(decades)*100, 1)}%)."
|
| 379 |
+
),
|
| 380 |
+
suggestion = (
|
| 381 |
+
"Include foundational older works and recent advances "
|
| 382 |
+
"to demonstrate comprehensive literature coverage."
|
| 383 |
+
),
|
| 384 |
+
))
|
| 385 |
+
|
| 386 |
+
return hhi
|
| 387 |
+
|
| 388 |
+
def _measure_geographic_diversity(
|
| 389 |
+
self, text: str, flags: list
|
| 390 |
+
) -> float:
|
| 391 |
+
"""
|
| 392 |
+
Scans the text for country/region markers to estimate how
|
| 393 |
+
internationally diverse the cited work is.
|
| 394 |
+
Single-country citation networks are a known bias signal.
|
| 395 |
+
"""
|
| 396 |
+
text_lo = text.lower()
|
| 397 |
+
found = [c for c in COUNTRY_MARKERS if c in text_lo]
|
| 398 |
+
unique = len(set(found))
|
| 399 |
+
|
| 400 |
+
# diversity score: 0 countries = 0.0, 5+ countries = 1.0
|
| 401 |
+
diversity = min(unique / 5.0, 1.0)
|
| 402 |
+
|
| 403 |
+
if diversity < 0.20 and unique <= 1:
|
| 404 |
+
flags.append(DNAFlag(
|
| 405 |
+
flag_type = "low_geographic_diversity",
|
| 406 |
+
severity = "low",
|
| 407 |
+
description = (
|
| 408 |
+
f"Citation network shows low geographic diversity. "
|
| 409 |
+
f"Only {unique} country/region marker(s) detected. "
|
| 410 |
+
f"International research should draw from global literature."
|
| 411 |
+
),
|
| 412 |
+
evidence = (
|
| 413 |
+
f"Countries/regions detected: "
|
| 414 |
+
f"{found if found else 'none identified'}."
|
| 415 |
+
),
|
| 416 |
+
suggestion = (
|
| 417 |
+
"Consider including research from institutions in "
|
| 418 |
+
"different countries to strengthen international validity."
|
| 419 |
+
),
|
| 420 |
+
))
|
| 421 |
+
|
| 422 |
+
return diversity
|
| 423 |
+
|
| 424 |
+
# ββ distributions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 425 |
+
|
| 426 |
+
def _year_distribution(self, nodes: list) -> dict:
|
| 427 |
+
years = [n.year for n in nodes if n.year]
|
| 428 |
+
return dict(sorted(Counter(years).items()))
|
| 429 |
+
|
| 430 |
+
def _journal_distribution(self, nodes: list) -> dict:
|
| 431 |
+
journals = [n.journal for n in nodes if n.journal]
|
| 432 |
+
return dict(Counter(journals).most_common(10))
|
| 433 |
+
|
| 434 |
+
# ββ hhi index βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 435 |
+
|
| 436 |
+
def _hhi(self, items: list) -> float:
|
| 437 |
+
"""
|
| 438 |
+
Herfindahl-Hirschman Index β standard economics concentration measure.
|
| 439 |
+
Sum of squared market shares. Range: 1/n (perfect diversity) to 1.0 (monopoly).
|
| 440 |
+
We normalize to 0-1 for consistent interpretation.
|
| 441 |
+
"""
|
| 442 |
+
if not items:
|
| 443 |
+
return 0.0
|
| 444 |
+
total = len(items)
|
| 445 |
+
counter = Counter(items)
|
| 446 |
+
raw_hhi = sum((count / total) ** 2 for count in counter.values())
|
| 447 |
+
min_hhi = 1.0 / len(counter) if counter else 0.0
|
| 448 |
+
if min_hhi >= 1.0:
|
| 449 |
+
return 1.0
|
| 450 |
+
normalized = (raw_hhi - min_hhi) / (1.0 - min_hhi)
|
| 451 |
+
return round(min(max(normalized, 0.0), 1.0), 4)
|
| 452 |
+
|
| 453 |
+
# ββ scoring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 454 |
+
|
| 455 |
+
def _compute_diversity(
|
| 456 |
+
self,
|
| 457 |
+
author_conc: float,
|
| 458 |
+
journal_conc: float,
|
| 459 |
+
decade_conc: float,
|
| 460 |
+
geo_div: float,
|
| 461 |
+
) -> float:
|
| 462 |
+
"""
|
| 463 |
+
Diversity is the inverse of concentration across all dimensions.
|
| 464 |
+
Geographic diversity adds to the score rather than subtracting.
|
| 465 |
+
"""
|
| 466 |
+
concentration = (
|
| 467 |
+
author_conc * 0.35 +
|
| 468 |
+
journal_conc * 0.30 +
|
| 469 |
+
decade_conc * 0.20
|
| 470 |
+
)
|
| 471 |
+
diversity = (1.0 - concentration) * 0.75 + geo_div * 0.25
|
| 472 |
+
return round(min(max(diversity, 0.0), 1.0), 3)
|
| 473 |
+
|
| 474 |
+
def _get_risk_level(self, score: float) -> str:
|
| 475 |
+
if score >= 0.70: return "critical"
|
| 476 |
+
if score >= 0.45: return "high"
|
| 477 |
+
if score >= 0.25: return "medium"
|
| 478 |
+
return "low"
|
| 479 |
+
|
| 480 |
+
def _write_summary(
|
| 481 |
+
self,
|
| 482 |
+
nodes: list,
|
| 483 |
+
flags: list,
|
| 484 |
+
risk_level: str,
|
| 485 |
+
diversity: float,
|
| 486 |
+
) -> str:
|
| 487 |
+
n = len(nodes)
|
| 488 |
+
if n == 0:
|
| 489 |
+
return (
|
| 490 |
+
"Citation DNA Analysis: No citations extracted. "
|
| 491 |
+
"Paste the full references section for complete network analysis. "
|
| 492 |
+
f"Risk level: {risk_level.upper()}."
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
if not flags:
|
| 496 |
+
return (
|
| 497 |
+
f"Citation DNA Analysis: {n} citation(s) analyzed. "
|
| 498 |
+
f"Network diversity score: {round(diversity * 100, 1)}%. "
|
| 499 |
+
f"Citation network appears healthy and diverse. "
|
| 500 |
+
f"Risk level: {risk_level.upper()}."
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 504 |
+
medium = sum(1 for f in flags if f.severity == "medium")
|
| 505 |
+
parts = []
|
| 506 |
+
if high:
|
| 507 |
+
parts.append(f"{high} high-severity concentration issue{'s' if high > 1 else ''}")
|
| 508 |
+
if medium:
|
| 509 |
+
parts.append(f"{medium} medium-severity pattern{'s' if medium > 1 else ''}")
|
| 510 |
+
|
| 511 |
+
return (
|
| 512 |
+
f"Citation DNA Analysis: {n} citation(s) analyzed. "
|
| 513 |
+
f"Network diversity: {round(diversity * 100, 1)}%. "
|
| 514 |
+
f"{', '.join(parts)} detected. "
|
| 515 |
+
f"Risk level: {risk_level.upper()}."
|
| 516 |
+
)
|
src/scipeerai/modules/data_fingerprint.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cross-Paper Data Fingerprinting
|
| 2 |
+
# --------------------------------
|
| 3 |
+
# Independent studies on the same topic will produce
|
| 4 |
+
# similar but never identical numbers.
|
| 5 |
+
# Random sampling variation guarantees this.
|
| 6 |
+
#
|
| 7 |
+
# When two papers report the exact same mean to four
|
| 8 |
+
# decimal places, the same standard deviation, the same
|
| 9 |
+
# sample size, and the same p-value β they are not
|
| 10 |
+
# independent. One copied from the other, or both
|
| 11 |
+
# copied from a shared fabricated source.
|
| 12 |
+
#
|
| 13 |
+
# This module extracts the numerical fingerprint of a
|
| 14 |
+
# single paper: every mean, SD, sample size, percentage,
|
| 15 |
+
# correlation, and p-value it reports.
|
| 16 |
+
#
|
| 17 |
+
# That fingerprint can then be compared against others.
|
| 18 |
+
# But even in isolation, the fingerprint reveals problems:
|
| 19 |
+
# numbers that are suspiciously round, values that are
|
| 20 |
+
# mathematically impossible given each other, and
|
| 21 |
+
# distributions of digits that do not look like real data.
|
| 22 |
+
#
|
| 23 |
+
# A paper's numbers should look like they came from
|
| 24 |
+
# the world. When they look like they came from a
|
| 25 |
+
# spreadsheet cell someone typed by hand β that is a signal.
|
| 26 |
+
|
| 27 |
+
import re
|
| 28 |
+
import math
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from collections import Counter
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ββ data structures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class NumericFingerprint:
|
| 37 |
+
means: list
|
| 38 |
+
std_devs: list
|
| 39 |
+
sample_sizes: list
|
| 40 |
+
percentages: list
|
| 41 |
+
correlations: list
|
| 42 |
+
p_values: list
|
| 43 |
+
all_decimals: list
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class DataFingerprintFlag:
|
| 48 |
+
flag_type: str
|
| 49 |
+
severity: str
|
| 50 |
+
description: str
|
| 51 |
+
evidence: str
|
| 52 |
+
suggestion: str
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class DataFingerprintResult:
|
| 57 |
+
fingerprint: NumericFingerprint
|
| 58 |
+
total_numbers: int
|
| 59 |
+
round_number_ratio: float
|
| 60 |
+
terminal_digit_bias: float
|
| 61 |
+
impossible_pairs: list
|
| 62 |
+
suspicious_duplicates: list
|
| 63 |
+
fingerprint_score: float
|
| 64 |
+
risk_level: str
|
| 65 |
+
summary: str
|
| 66 |
+
flags: list
|
| 67 |
+
flags_count: int
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
class DataFingerprintAnalyzer:
|
| 73 |
+
"""
|
| 74 |
+
Extracts the complete numerical fingerprint of a paper
|
| 75 |
+
and tests it for signs of fabrication or cloning.
|
| 76 |
+
|
| 77 |
+
Four detection layers:
|
| 78 |
+
1. Round number bias β fabricated data rounds too cleanly
|
| 79 |
+
2. Terminal digit bias β humans avoid certain ending digits
|
| 80 |
+
3. Impossible value pairs β SD larger than mean for positive scales
|
| 81 |
+
4. Suspicious internal duplicates β same value repeated too often
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
# regex patterns for specific statistical values
|
| 85 |
+
_MEAN_PATTERN = re.compile(
|
| 86 |
+
r'(?:mean|average|M)\s*[=:]\s*(-?\d+\.?\d*)', re.IGNORECASE
|
| 87 |
+
)
|
| 88 |
+
_SD_PATTERN = re.compile(
|
| 89 |
+
r'(?:SD|S\.D\.|std|standard deviation)\s*[=:]\s*(\d+\.?\d*)',
|
| 90 |
+
re.IGNORECASE
|
| 91 |
+
)
|
| 92 |
+
_N_PATTERN = re.compile(
|
| 93 |
+
r'(?:N|n|sample size)\s*[=:]\s*(\d+)', re.IGNORECASE
|
| 94 |
+
)
|
| 95 |
+
_PCT_PATTERN = re.compile(
|
| 96 |
+
r'(\d+\.?\d*)\s*%'
|
| 97 |
+
)
|
| 98 |
+
_CORR_PATTERN = re.compile(
|
| 99 |
+
r'(?:r|correlation)\s*[=:]\s*(-?\d*\.?\d+)', re.IGNORECASE
|
| 100 |
+
)
|
| 101 |
+
_PVAL_PATTERN = re.compile(
|
| 102 |
+
r'p\s*[=<>]\s*(0?\.\d+|\d+\.\d+[eE][+-]?\d+)', re.IGNORECASE
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def analyze(self, text: str) -> DataFingerprintResult:
|
| 106 |
+
fp = self._extract_fingerprint(text)
|
| 107 |
+
flags = []
|
| 108 |
+
|
| 109 |
+
round_ratio = self._check_round_number_bias(fp, flags)
|
| 110 |
+
terminal_bias = self._check_terminal_digit_bias(fp, flags)
|
| 111 |
+
impossible = self._check_impossible_pairs(fp, flags)
|
| 112 |
+
duplicates = self._check_suspicious_duplicates(fp, flags)
|
| 113 |
+
|
| 114 |
+
total = self._count_total(fp)
|
| 115 |
+
score = self._compute_score(
|
| 116 |
+
round_ratio, terminal_bias, impossible, duplicates, total
|
| 117 |
+
)
|
| 118 |
+
level = self._get_risk_level(score)
|
| 119 |
+
|
| 120 |
+
return DataFingerprintResult(
|
| 121 |
+
fingerprint = fp,
|
| 122 |
+
total_numbers = total,
|
| 123 |
+
round_number_ratio = round(round_ratio, 3),
|
| 124 |
+
terminal_digit_bias = round(terminal_bias, 3),
|
| 125 |
+
impossible_pairs = impossible,
|
| 126 |
+
suspicious_duplicates = duplicates,
|
| 127 |
+
fingerprint_score = round(score, 3),
|
| 128 |
+
risk_level = level,
|
| 129 |
+
summary = self._write_summary(flags, level, total),
|
| 130 |
+
flags = flags,
|
| 131 |
+
flags_count = len(flags),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# ββ extraction βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
|
| 136 |
+
def _extract_fingerprint(self, text: str) -> NumericFingerprint:
|
| 137 |
+
means = self._parse_floats(self._MEAN_PATTERN, text)
|
| 138 |
+
std_devs = self._parse_floats(self._SD_PATTERN, text)
|
| 139 |
+
sample_sizes = self._parse_ints( self._N_PATTERN, text)
|
| 140 |
+
percentages = self._parse_floats(self._PCT_PATTERN, text)
|
| 141 |
+
correlations = self._parse_floats(self._CORR_PATTERN, text)
|
| 142 |
+
p_values = self._parse_floats(self._PVAL_PATTERN, text)
|
| 143 |
+
|
| 144 |
+
# all decimal numbers in the paper for digit-level analysis
|
| 145 |
+
all_decimals = [
|
| 146 |
+
float(m.group())
|
| 147 |
+
for m in re.finditer(r'-?\d+\.\d+', text)
|
| 148 |
+
if self._safe_float(m.group()) is not None
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
return NumericFingerprint(
|
| 152 |
+
means = means,
|
| 153 |
+
std_devs = std_devs,
|
| 154 |
+
sample_sizes = sample_sizes,
|
| 155 |
+
percentages = percentages,
|
| 156 |
+
correlations = correlations,
|
| 157 |
+
p_values = p_values,
|
| 158 |
+
all_decimals = all_decimals,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def _parse_floats(self, pattern: re.Pattern, text: str) -> list:
|
| 162 |
+
results = []
|
| 163 |
+
for match in pattern.finditer(text):
|
| 164 |
+
val = self._safe_float(match.group(1))
|
| 165 |
+
if val is not None:
|
| 166 |
+
results.append(val)
|
| 167 |
+
return results
|
| 168 |
+
|
| 169 |
+
def _parse_ints(self, pattern: re.Pattern, text: str) -> list:
|
| 170 |
+
results = []
|
| 171 |
+
for match in pattern.finditer(text):
|
| 172 |
+
try:
|
| 173 |
+
val = int(match.group(1))
|
| 174 |
+
if 1 <= val <= 1_000_000:
|
| 175 |
+
results.append(val)
|
| 176 |
+
except (ValueError, IndexError):
|
| 177 |
+
pass
|
| 178 |
+
return results
|
| 179 |
+
|
| 180 |
+
def _safe_float(self, raw: str) -> float:
|
| 181 |
+
try:
|
| 182 |
+
return float(raw.strip())
|
| 183 |
+
except (ValueError, AttributeError):
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
# ββ detection checks βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 187 |
+
|
| 188 |
+
def _check_round_number_bias(
|
| 189 |
+
self, fp: NumericFingerprint, flags: list
|
| 190 |
+
) -> float:
|
| 191 |
+
"""
|
| 192 |
+
Real data does not round to whole numbers or .5 steps very often.
|
| 193 |
+
When more than 60% of reported values are suspiciously round,
|
| 194 |
+
someone likely typed them rather than computed them.
|
| 195 |
+
"""
|
| 196 |
+
all_vals = fp.means + fp.std_devs + fp.percentages
|
| 197 |
+
if len(all_vals) < 4:
|
| 198 |
+
return 0.0
|
| 199 |
+
|
| 200 |
+
round_count = sum(
|
| 201 |
+
1 for v in all_vals
|
| 202 |
+
if v == round(v, 0) or v == round(v, 1) and str(v).endswith(('0', '5'))
|
| 203 |
+
)
|
| 204 |
+
ratio = round_count / len(all_vals)
|
| 205 |
+
|
| 206 |
+
if ratio >= 0.60:
|
| 207 |
+
flags.append(DataFingerprintFlag(
|
| 208 |
+
flag_type = "round_number_bias",
|
| 209 |
+
severity = "medium",
|
| 210 |
+
description = (
|
| 211 |
+
f"{round_count}/{len(all_vals)} reported values "
|
| 212 |
+
f"({round(ratio * 100, 1)}%) are suspiciously round. "
|
| 213 |
+
f"Real measured data rarely rounds this cleanly."
|
| 214 |
+
),
|
| 215 |
+
evidence = (
|
| 216 |
+
f"Round values detected among means, SDs, and percentages. "
|
| 217 |
+
f"Round ratio: {round(ratio, 3)}."
|
| 218 |
+
),
|
| 219 |
+
suggestion = (
|
| 220 |
+
"Verify that reported values are directly from analysis "
|
| 221 |
+
"output, not manually entered approximations."
|
| 222 |
+
),
|
| 223 |
+
))
|
| 224 |
+
|
| 225 |
+
return ratio
|
| 226 |
+
|
| 227 |
+
def _check_terminal_digit_bias(
|
| 228 |
+
self, fp: NumericFingerprint, flags: list
|
| 229 |
+
) -> float:
|
| 230 |
+
"""
|
| 231 |
+
The last digit of a truly random number is uniformly distributed
|
| 232 |
+
across 0-9. Humans fabricating numbers unconsciously prefer
|
| 233 |
+
certain digits (0, 5) and avoid others (7, 9).
|
| 234 |
+
A chi-square test on terminal digits detects this.
|
| 235 |
+
"""
|
| 236 |
+
all_vals = fp.all_decimals + [float(n) for n in fp.sample_sizes]
|
| 237 |
+
if len(all_vals) < 10:
|
| 238 |
+
return 0.0
|
| 239 |
+
|
| 240 |
+
terminals = []
|
| 241 |
+
for v in all_vals:
|
| 242 |
+
parts = str(abs(v)).replace('.', '')
|
| 243 |
+
if parts:
|
| 244 |
+
terminals.append(int(parts[-1]))
|
| 245 |
+
|
| 246 |
+
if not terminals:
|
| 247 |
+
return 0.0
|
| 248 |
+
|
| 249 |
+
counter = Counter(terminals)
|
| 250 |
+
expected = len(terminals) / 10.0
|
| 251 |
+
chi_sq = sum(
|
| 252 |
+
((counter.get(d, 0) - expected) ** 2) / expected
|
| 253 |
+
for d in range(10)
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
# chi-square critical value at p=0.05 with 9 df is 16.92
|
| 257 |
+
bias_score = min(chi_sq / 50.0, 1.0)
|
| 258 |
+
|
| 259 |
+
if chi_sq >= 16.92:
|
| 260 |
+
dominant_digit = counter.most_common(1)[0]
|
| 261 |
+
flags.append(DataFingerprintFlag(
|
| 262 |
+
flag_type = "terminal_digit_bias",
|
| 263 |
+
severity = "medium",
|
| 264 |
+
description = (
|
| 265 |
+
f"Terminal digit distribution deviates significantly "
|
| 266 |
+
f"from uniform expectation. "
|
| 267 |
+
f"Chi-square statistic: {round(chi_sq, 2)} "
|
| 268 |
+
f"(critical value: 16.92). "
|
| 269 |
+
f"This pattern is consistent with human number fabrication."
|
| 270 |
+
),
|
| 271 |
+
evidence = (
|
| 272 |
+
f"Most frequent terminal digit: "
|
| 273 |
+
f"'{dominant_digit[0]}' appears {dominant_digit[1]} times. "
|
| 274 |
+
f"Expected uniform frequency: {round(expected, 1)} each."
|
| 275 |
+
),
|
| 276 |
+
suggestion = (
|
| 277 |
+
"Re-examine raw data files to confirm reported values "
|
| 278 |
+
"match analysis output. Terminal digit bias is a "
|
| 279 |
+
"well-established fabrication marker."
|
| 280 |
+
),
|
| 281 |
+
))
|
| 282 |
+
|
| 283 |
+
return round(bias_score, 3)
|
| 284 |
+
|
| 285 |
+
def _check_impossible_pairs(
|
| 286 |
+
self, fp: NumericFingerprint, flags: list
|
| 287 |
+
) -> list:
|
| 288 |
+
"""
|
| 289 |
+
Statistical relationships constrain what values can coexist.
|
| 290 |
+
SD > mean is impossible for strictly positive Likert-scale data.
|
| 291 |
+
Correlation outside [-1, 1] is mathematically impossible.
|
| 292 |
+
P-value outside [0, 1] cannot exist.
|
| 293 |
+
"""
|
| 294 |
+
impossible = []
|
| 295 |
+
|
| 296 |
+
# SD > mean for positive scales (Likert 1-7, reaction times, etc.)
|
| 297 |
+
for mean, sd in zip(fp.means, fp.std_devs):
|
| 298 |
+
if mean > 0 and sd > mean * 2:
|
| 299 |
+
pair = f"M={mean}, SD={sd}"
|
| 300 |
+
impossible.append(pair)
|
| 301 |
+
|
| 302 |
+
if impossible:
|
| 303 |
+
flags.append(DataFingerprintFlag(
|
| 304 |
+
flag_type = "impossible_sd_mean_pair",
|
| 305 |
+
severity = "high",
|
| 306 |
+
description = (
|
| 307 |
+
f"{len(impossible)} mean/SD pair(s) where the standard "
|
| 308 |
+
f"deviation is implausibly large relative to the mean. "
|
| 309 |
+
f"For bounded positive scales, SD > 2*mean is suspicious."
|
| 310 |
+
),
|
| 311 |
+
evidence = f"Impossible pairs: {impossible[:3]}.",
|
| 312 |
+
suggestion = (
|
| 313 |
+
"Verify these values against the original analysis output. "
|
| 314 |
+
"Large SDs relative to means may indicate data entry error "
|
| 315 |
+
"or scale confusion."
|
| 316 |
+
),
|
| 317 |
+
))
|
| 318 |
+
|
| 319 |
+
# correlation outside valid range
|
| 320 |
+
bad_corr = [r for r in fp.correlations if abs(r) > 1.0]
|
| 321 |
+
if bad_corr:
|
| 322 |
+
impossible.extend([f"r={r}" for r in bad_corr])
|
| 323 |
+
flags.append(DataFingerprintFlag(
|
| 324 |
+
flag_type = "impossible_correlation",
|
| 325 |
+
severity = "high",
|
| 326 |
+
description = (
|
| 327 |
+
f"{len(bad_corr)} correlation value(s) outside [-1, 1]. "
|
| 328 |
+
f"These values are mathematically impossible."
|
| 329 |
+
),
|
| 330 |
+
evidence = f"Invalid correlations: {bad_corr}.",
|
| 331 |
+
suggestion = "Correct these values before submission.",
|
| 332 |
+
))
|
| 333 |
+
|
| 334 |
+
# p-value outside [0, 1]
|
| 335 |
+
bad_p = [p for p in fp.p_values if p < 0 or p > 1]
|
| 336 |
+
if bad_p:
|
| 337 |
+
impossible.extend([f"p={p}" for p in bad_p])
|
| 338 |
+
flags.append(DataFingerprintFlag(
|
| 339 |
+
flag_type = "impossible_p_value",
|
| 340 |
+
severity = "high",
|
| 341 |
+
description = (
|
| 342 |
+
f"{len(bad_p)} p-value(s) outside [0, 1]. "
|
| 343 |
+
f"These values cannot exist."
|
| 344 |
+
),
|
| 345 |
+
evidence = f"Invalid p-values: {bad_p}.",
|
| 346 |
+
suggestion = "Check analysis code for unit or scale errors.",
|
| 347 |
+
))
|
| 348 |
+
|
| 349 |
+
return impossible
|
| 350 |
+
|
| 351 |
+
def _check_suspicious_duplicates(
|
| 352 |
+
self, fp: NumericFingerprint, flags: list
|
| 353 |
+
) -> list:
|
| 354 |
+
"""
|
| 355 |
+
The same specific decimal value appearing 3+ times in a paper
|
| 356 |
+
is unusual unless it is a threshold or constant.
|
| 357 |
+
In fabricated data, a single invented number gets reused.
|
| 358 |
+
"""
|
| 359 |
+
all_vals = fp.means + fp.std_devs + fp.percentages + fp.correlations
|
| 360 |
+
if len(all_vals) < 6:
|
| 361 |
+
return []
|
| 362 |
+
|
| 363 |
+
counter = Counter(all_vals)
|
| 364 |
+
duplicates = [
|
| 365 |
+
v for v, count in counter.items()
|
| 366 |
+
if count >= 3 and v not in (0.0, 1.0, 0.5, 100.0, 0.05)
|
| 367 |
+
]
|
| 368 |
+
|
| 369 |
+
if duplicates:
|
| 370 |
+
flags.append(DataFingerprintFlag(
|
| 371 |
+
flag_type = "suspicious_value_repetition",
|
| 372 |
+
severity = "medium",
|
| 373 |
+
description = (
|
| 374 |
+
f"{len(duplicates)} specific value(s) appear 3 or more "
|
| 375 |
+
f"times across different reported statistics. "
|
| 376 |
+
f"Genuine independent measurements rarely share "
|
| 377 |
+
f"exact decimal values."
|
| 378 |
+
),
|
| 379 |
+
evidence = (
|
| 380 |
+
f"Repeated values: "
|
| 381 |
+
f"{[round(v, 4) for v in duplicates[:5]]}."
|
| 382 |
+
),
|
| 383 |
+
suggestion = (
|
| 384 |
+
"Verify that repeated values reflect genuinely "
|
| 385 |
+
"identical measurements and are not copy-paste artifacts."
|
| 386 |
+
),
|
| 387 |
+
))
|
| 388 |
+
|
| 389 |
+
return duplicates
|
| 390 |
+
|
| 391 |
+
# ββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 392 |
+
|
| 393 |
+
def _count_total(self, fp: NumericFingerprint) -> int:
|
| 394 |
+
return (
|
| 395 |
+
len(fp.means) + len(fp.std_devs) + len(fp.sample_sizes) +
|
| 396 |
+
len(fp.percentages) + len(fp.correlations) + len(fp.p_values)
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# ββ scoring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 400 |
+
|
| 401 |
+
def _compute_score(
|
| 402 |
+
self,
|
| 403 |
+
round_ratio: float,
|
| 404 |
+
terminal_bias: float,
|
| 405 |
+
impossible: list,
|
| 406 |
+
duplicates: list,
|
| 407 |
+
total: int,
|
| 408 |
+
) -> float:
|
| 409 |
+
if total == 0:
|
| 410 |
+
return 0.0
|
| 411 |
+
|
| 412 |
+
impossible_score = min(len(impossible) * 0.25, 1.0)
|
| 413 |
+
duplicate_score = min(len(duplicates) * 0.15, 1.0)
|
| 414 |
+
|
| 415 |
+
score = (
|
| 416 |
+
round_ratio * 0.25 +
|
| 417 |
+
terminal_bias * 0.25 +
|
| 418 |
+
impossible_score * 0.35 +
|
| 419 |
+
duplicate_score * 0.15
|
| 420 |
+
)
|
| 421 |
+
return min(score, 1.0)
|
| 422 |
+
|
| 423 |
+
def _get_risk_level(self, score: float) -> str:
|
| 424 |
+
if score >= 0.70: return "critical"
|
| 425 |
+
if score >= 0.45: return "high"
|
| 426 |
+
if score >= 0.25: return "medium"
|
| 427 |
+
return "low"
|
| 428 |
+
|
| 429 |
+
def _write_summary(
|
| 430 |
+
self, flags: list, risk_level: str, total: int
|
| 431 |
+
) -> str:
|
| 432 |
+
if total == 0:
|
| 433 |
+
return (
|
| 434 |
+
"Data Fingerprint Analysis: No statistical values extracted. "
|
| 435 |
+
"Include explicit M=, SD=, N=, r=, and p= reporting "
|
| 436 |
+
f"for full analysis. Risk level: {risk_level.upper()}."
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
if not flags:
|
| 440 |
+
return (
|
| 441 |
+
f"Data Fingerprint Analysis: {total} statistical value(s) "
|
| 442 |
+
f"analyzed. No fabrication signals detected. "
|
| 443 |
+
f"Numerical patterns appear consistent with genuine data. "
|
| 444 |
+
f"Risk level: {risk_level.upper()}."
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 448 |
+
medium = sum(1 for f in flags if f.severity == "medium")
|
| 449 |
+
parts = []
|
| 450 |
+
if high:
|
| 451 |
+
parts.append(
|
| 452 |
+
f"{high} impossible value{'s' if high > 1 else ''} detected"
|
| 453 |
+
)
|
| 454 |
+
if medium:
|
| 455 |
+
parts.append(
|
| 456 |
+
f"{medium} fabrication signal{'s' if medium > 1 else ''} found"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
return (
|
| 460 |
+
f"Data Fingerprint Analysis: {total} value(s) analyzed. "
|
| 461 |
+
f"{'; '.join(parts)}. "
|
| 462 |
+
f"Risk level: {risk_level.upper()}."
|
| 463 |
+
)
|
src/scipeerai/modules/effect_size_validator.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/effect_size_validator.py
|
| 2 |
+
#
|
| 3 |
+
# Effect Size Validator
|
| 4 |
+
# Extracts and validates Cohen's d, r, eta-squared,
|
| 5 |
+
# odds ratios, and performs post-hoc power analysis.
|
| 6 |
+
#
|
| 7 |
+
# Small N + large effect size = fabrication signal.
|
| 8 |
+
# Underpowered studies with significant results = suspect.
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import math
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class EffectSizeFlag:
|
| 17 |
+
flag_type: str
|
| 18 |
+
severity: str
|
| 19 |
+
description: str
|
| 20 |
+
evidence: str
|
| 21 |
+
suggestion: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class EffectSizeResult:
|
| 26 |
+
effect_sizes_found: list
|
| 27 |
+
power_estimates: list
|
| 28 |
+
inflated_effects: list
|
| 29 |
+
underpowered: list
|
| 30 |
+
effect_score: float
|
| 31 |
+
risk_level: str
|
| 32 |
+
summary: str
|
| 33 |
+
flags: list = field(default_factory=list)
|
| 34 |
+
flags_count: int = 0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class EffectSizeValidator:
|
| 38 |
+
"""
|
| 39 |
+
Effect Size Validator.
|
| 40 |
+
Validates reported effect sizes against sample sizes.
|
| 41 |
+
Detects inflated effects and underpowered studies.
|
| 42 |
+
|
| 43 |
+
Key insight:
|
| 44 |
+
- Real large effects (d>0.8) need N>50 to be credible
|
| 45 |
+
- Small N + large effect = likely false positive
|
| 46 |
+
- Significant result + low power = suspicious
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
# Cohen's d pattern
|
| 50 |
+
COHENS_D = re.compile(
|
| 51 |
+
r"cohen['\s]?s?\s*d\s*[=:]\s*(-?\d+\.?\d*)",
|
| 52 |
+
re.IGNORECASE
|
| 53 |
+
)
|
| 54 |
+
# Pearson r
|
| 55 |
+
PEARSON_R = re.compile(
|
| 56 |
+
r"\br\s*[=:]\s*(-?0?\.\d+)",
|
| 57 |
+
re.IGNORECASE
|
| 58 |
+
)
|
| 59 |
+
# Eta squared
|
| 60 |
+
ETA_SQ = re.compile(
|
| 61 |
+
r"eta[Β²2\s-]*squared?\s*[=:]\s*(0?\.\d+)",
|
| 62 |
+
re.IGNORECASE
|
| 63 |
+
)
|
| 64 |
+
# Omega squared
|
| 65 |
+
OMEGA_SQ = re.compile(
|
| 66 |
+
r"omega[Β²2\s-]*squared?\s*[=:]\s*(0?\.\d+)",
|
| 67 |
+
re.IGNORECASE
|
| 68 |
+
)
|
| 69 |
+
# Odds ratio
|
| 70 |
+
ODDS_R = re.compile(
|
| 71 |
+
r"odds\s*ratio\s*[=:]\s*(\d+\.?\d*)",
|
| 72 |
+
re.IGNORECASE
|
| 73 |
+
)
|
| 74 |
+
# Sample size
|
| 75 |
+
N_PAT = re.compile(
|
| 76 |
+
r"\bn\s*[=:]\s*(\d+)",
|
| 77 |
+
re.IGNORECASE
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Cohen's benchmarks
|
| 81 |
+
COHENS_BENCHMARKS = {
|
| 82 |
+
"small": 0.2,
|
| 83 |
+
"medium": 0.5,
|
| 84 |
+
"large": 0.8,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
def analyze(self, text: str) -> EffectSizeResult:
|
| 88 |
+
effects = self._extract_effects(text)
|
| 89 |
+
ns = self._extract_ns(text)
|
| 90 |
+
n_val = min(ns) if ns else None
|
| 91 |
+
|
| 92 |
+
flags = []
|
| 93 |
+
inflated = []
|
| 94 |
+
underpowered = []
|
| 95 |
+
power_ests = []
|
| 96 |
+
|
| 97 |
+
for etype, evalue in effects:
|
| 98 |
+
# ββ Power estimation ββββββββββββββββββββββββββββββββββ
|
| 99 |
+
if n_val and etype == "cohens_d":
|
| 100 |
+
power = self._estimate_power(evalue, n_val)
|
| 101 |
+
power_ests.append({
|
| 102 |
+
"effect_type": etype,
|
| 103 |
+
"effect_value": evalue,
|
| 104 |
+
"n": n_val,
|
| 105 |
+
"power": round(power, 3),
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
# ββ Flag: inflated effect size βββββββββββββββββββββ
|
| 109 |
+
if abs(evalue) > 2.0 and n_val < 30:
|
| 110 |
+
inflated.append((etype, evalue, n_val))
|
| 111 |
+
flags.append(EffectSizeFlag(
|
| 112 |
+
flag_type = "inflated_effect_size",
|
| 113 |
+
severity = "high",
|
| 114 |
+
description = (
|
| 115 |
+
f"Cohen's d = {evalue} is extremely large "
|
| 116 |
+
f"with only N = {n_val}. Effect sizes above "
|
| 117 |
+
f"d = 2.0 with small samples are rarely "
|
| 118 |
+
f"genuine β likely reflects noise, "
|
| 119 |
+
f"outliers, or fabrication."
|
| 120 |
+
),
|
| 121 |
+
evidence = (
|
| 122 |
+
f"Cohen's d = {evalue}, N = {n_val} | "
|
| 123 |
+
f"Expected power: {round(power*100)}% | "
|
| 124 |
+
f"Cohen's large effect benchmark: d = 0.8"
|
| 125 |
+
),
|
| 126 |
+
suggestion = (
|
| 127 |
+
"Report confidence intervals for effect "
|
| 128 |
+
"sizes. Conduct sensitivity analysis. "
|
| 129 |
+
"Verify no outliers are driving the effect."
|
| 130 |
+
),
|
| 131 |
+
))
|
| 132 |
+
|
| 133 |
+
# ββ Flag: underpowered study βββββββββββββββββββββββ
|
| 134 |
+
elif power < 0.8 and n_val < 50:
|
| 135 |
+
underpowered.append((etype, evalue, n_val, power))
|
| 136 |
+
flags.append(EffectSizeFlag(
|
| 137 |
+
flag_type = "underpowered_study",
|
| 138 |
+
severity = "medium",
|
| 139 |
+
description = (
|
| 140 |
+
f"Study is underpowered (estimated power = "
|
| 141 |
+
f"{round(power*100)}%). With N = {n_val} and "
|
| 142 |
+
f"d = {evalue}, there is only a "
|
| 143 |
+
f"{round(power*100)}% chance of detecting "
|
| 144 |
+
f"a real effect. Significant results from "
|
| 145 |
+
f"underpowered studies are likely false positives."
|
| 146 |
+
),
|
| 147 |
+
evidence = (
|
| 148 |
+
f"Cohen's d = {evalue}, N = {n_val} | "
|
| 149 |
+
f"Estimated power = {round(power*100)}% "
|
| 150 |
+
f"(recommended minimum: 80%)"
|
| 151 |
+
),
|
| 152 |
+
suggestion = (
|
| 153 |
+
"Conduct a priori power analysis. "
|
| 154 |
+
"Increase sample size to achieve 80% power. "
|
| 155 |
+
"Report power analysis in methods section."
|
| 156 |
+
),
|
| 157 |
+
))
|
| 158 |
+
|
| 159 |
+
# ββ Flag: impossible r value βββββββββββββββββββββββββββ
|
| 160 |
+
if etype == "pearson_r" and abs(evalue) > 1.0:
|
| 161 |
+
flags.append(EffectSizeFlag(
|
| 162 |
+
flag_type = "impossible_correlation",
|
| 163 |
+
severity = "high",
|
| 164 |
+
description = (
|
| 165 |
+
f"Pearson r = {evalue} is impossible β "
|
| 166 |
+
f"correlations must be between -1 and 1. "
|
| 167 |
+
f"This indicates a reporting error or fabrication."
|
| 168 |
+
),
|
| 169 |
+
evidence = f"r = {evalue} reported",
|
| 170 |
+
suggestion = (
|
| 171 |
+
"Verify raw correlation values. "
|
| 172 |
+
"Check if rΒ² was mistakenly reported as r."
|
| 173 |
+
),
|
| 174 |
+
))
|
| 175 |
+
|
| 176 |
+
# ββ Flag: suspiciously large eta squared ββββββββββββββ
|
| 177 |
+
if etype == "eta_squared" and evalue > 0.5:
|
| 178 |
+
flags.append(EffectSizeFlag(
|
| 179 |
+
flag_type = "large_eta_squared",
|
| 180 |
+
severity = "medium",
|
| 181 |
+
description = (
|
| 182 |
+
f"Eta-squared = {evalue} is unusually large. "
|
| 183 |
+
f"Values above 0.5 are rare in behavioral and "
|
| 184 |
+
f"social science research and warrant scrutiny."
|
| 185 |
+
),
|
| 186 |
+
evidence = f"Ξ·Β² = {evalue} (large effect threshold: 0.14)",
|
| 187 |
+
suggestion = (
|
| 188 |
+
"Report partial eta-squared separately. "
|
| 189 |
+
"Verify ANOVA calculations and degrees of freedom."
|
| 190 |
+
),
|
| 191 |
+
))
|
| 192 |
+
|
| 193 |
+
# ββ Flag: no effect sizes reported ββββββββββββββββββββββββ
|
| 194 |
+
if len(effects) == 0:
|
| 195 |
+
flags.append(EffectSizeFlag(
|
| 196 |
+
flag_type = "missing_effect_sizes",
|
| 197 |
+
severity = "medium",
|
| 198 |
+
description = (
|
| 199 |
+
"No effect sizes reported in the paper. "
|
| 200 |
+
"Effect sizes (Cohen's d, r, eta-squared) are "
|
| 201 |
+
"essential for interpreting practical significance "
|
| 202 |
+
"and are required by most major journals."
|
| 203 |
+
),
|
| 204 |
+
evidence = "No Cohen's d, r, or eta-squared found",
|
| 205 |
+
suggestion = (
|
| 206 |
+
"Report effect sizes with confidence intervals "
|
| 207 |
+
"for all primary outcomes. Use Cohen's d for "
|
| 208 |
+
"mean differences, r for correlations."
|
| 209 |
+
),
|
| 210 |
+
))
|
| 211 |
+
|
| 212 |
+
score = self._aggregate_score(inflated, underpowered, effects)
|
| 213 |
+
level = self._risk(score, len(inflated), len(underpowered))
|
| 214 |
+
summary = self._build_summary(
|
| 215 |
+
effects, inflated, underpowered, score, level
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
return EffectSizeResult(
|
| 219 |
+
effect_sizes_found = effects,
|
| 220 |
+
power_estimates = power_ests,
|
| 221 |
+
inflated_effects = inflated,
|
| 222 |
+
underpowered = underpowered,
|
| 223 |
+
effect_score = round(score, 4),
|
| 224 |
+
risk_level = level,
|
| 225 |
+
summary = summary,
|
| 226 |
+
flags = flags,
|
| 227 |
+
flags_count = len(flags),
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# ββ internal helpers βββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
def _extract_effects(self, text: str) -> list:
|
| 233 |
+
effects = []
|
| 234 |
+
for m in self.COHENS_D.finditer(text):
|
| 235 |
+
try:
|
| 236 |
+
effects.append(("cohens_d", float(m.group(1))))
|
| 237 |
+
except ValueError:
|
| 238 |
+
pass
|
| 239 |
+
for m in self.PEARSON_R.finditer(text):
|
| 240 |
+
try:
|
| 241 |
+
v = float(m.group(1))
|
| 242 |
+
if -1.5 <= v <= 1.5:
|
| 243 |
+
effects.append(("pearson_r", v))
|
| 244 |
+
except ValueError:
|
| 245 |
+
pass
|
| 246 |
+
for m in self.ETA_SQ.finditer(text):
|
| 247 |
+
try:
|
| 248 |
+
effects.append(("eta_squared", float(m.group(1))))
|
| 249 |
+
except ValueError:
|
| 250 |
+
pass
|
| 251 |
+
for m in self.OMEGA_SQ.finditer(text):
|
| 252 |
+
try:
|
| 253 |
+
effects.append(("omega_squared", float(m.group(1))))
|
| 254 |
+
except ValueError:
|
| 255 |
+
pass
|
| 256 |
+
for m in self.ODDS_R.finditer(text):
|
| 257 |
+
try:
|
| 258 |
+
v = float(m.group(1))
|
| 259 |
+
if 0.1 <= v <= 50:
|
| 260 |
+
effects.append(("odds_ratio", v))
|
| 261 |
+
except ValueError:
|
| 262 |
+
pass
|
| 263 |
+
return effects
|
| 264 |
+
|
| 265 |
+
def _extract_ns(self, text: str) -> list:
|
| 266 |
+
ns = []
|
| 267 |
+
for m in self.N_PAT.finditer(text):
|
| 268 |
+
try:
|
| 269 |
+
v = int(m.group(1))
|
| 270 |
+
if 2 <= v <= 100000:
|
| 271 |
+
ns.append(v)
|
| 272 |
+
except ValueError:
|
| 273 |
+
pass
|
| 274 |
+
return ns
|
| 275 |
+
|
| 276 |
+
def _estimate_power(self, d: float, n: int) -> float:
|
| 277 |
+
"""
|
| 278 |
+
Approximate statistical power for two-sample t-test.
|
| 279 |
+
Uses normal approximation of non-central t distribution.
|
| 280 |
+
"""
|
| 281 |
+
try:
|
| 282 |
+
ncp = abs(d) * math.sqrt(n / 2)
|
| 283 |
+
power = 1 - self._normal_cdf(1.96 - ncp)
|
| 284 |
+
return min(max(power, 0.0), 1.0)
|
| 285 |
+
except Exception:
|
| 286 |
+
return 0.5
|
| 287 |
+
|
| 288 |
+
def _normal_cdf(self, x: float) -> float:
|
| 289 |
+
"""Approximation of standard normal CDF."""
|
| 290 |
+
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
|
| 291 |
+
|
| 292 |
+
def _aggregate_score(self, inflated, underpowered,
|
| 293 |
+
effects) -> float:
|
| 294 |
+
if not effects:
|
| 295 |
+
return 0.3
|
| 296 |
+
score = 0.0
|
| 297 |
+
if inflated:
|
| 298 |
+
score += 0.5 * min(len(inflated), 2) / 2
|
| 299 |
+
if underpowered:
|
| 300 |
+
score += 0.3 * min(len(underpowered), 2) / 2
|
| 301 |
+
return min(score, 1.0)
|
| 302 |
+
|
| 303 |
+
def _risk(self, score: float,
|
| 304 |
+
n_inflated: int,
|
| 305 |
+
n_underpowered: int) -> str:
|
| 306 |
+
if n_inflated >= 1 or score >= 0.6:
|
| 307 |
+
return "critical"
|
| 308 |
+
if n_underpowered >= 2 or score >= 0.4:
|
| 309 |
+
return "high"
|
| 310 |
+
if n_underpowered >= 1 or score >= 0.2:
|
| 311 |
+
return "medium"
|
| 312 |
+
return "low"
|
| 313 |
+
|
| 314 |
+
def _build_summary(self, effects, inflated,
|
| 315 |
+
underpowered, score, level) -> str:
|
| 316 |
+
if not effects:
|
| 317 |
+
return (
|
| 318 |
+
"Effect Size Validation: No effect sizes detected. "
|
| 319 |
+
"Cohen's d, r, or eta-squared reporting is recommended "
|
| 320 |
+
"for all primary outcomes. Risk level: MEDIUM."
|
| 321 |
+
)
|
| 322 |
+
pct = round(score * 100)
|
| 323 |
+
return (
|
| 324 |
+
f"Effect Size Validator analyzed {len(effects)} effect "
|
| 325 |
+
f"size(s). {len(inflated)} inflated, "
|
| 326 |
+
f"{len(underpowered)} underpowered study/studies detected. "
|
| 327 |
+
f"Overall risk score: {pct}%. "
|
| 328 |
+
f"Risk level: {level.upper()}."
|
| 329 |
+
)
|
src/scipeerai/modules/field_saturation.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/field_saturation.py
|
| 2 |
+
#
|
| 3 |
+
# Module 23: Field Saturation Detector
|
| 4 |
+
# Detects over-published research topics, measures novelty space,
|
| 5 |
+
# identifies redundant contribution patterns, and scores field
|
| 6 |
+
# crowding relative to claimed contributions.
|
| 7 |
+
#
|
| 8 |
+
# Score attribute: saturation_score (0.0 = fresh field, 1.0 = saturated)
|
| 9 |
+
# Part of SciPeerAI Phase 6 β v2.3.0
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import math
|
| 15 |
+
from collections import Counter
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ββ Data classes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class SaturationFlag:
|
| 23 |
+
flag_type: str
|
| 24 |
+
severity: str
|
| 25 |
+
description: str
|
| 26 |
+
evidence: str
|
| 27 |
+
suggestion: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class SaturationResult:
|
| 32 |
+
topic_keywords: list
|
| 33 |
+
keyword_density: float
|
| 34 |
+
redundancy_score: float
|
| 35 |
+
novelty_claim_score: float
|
| 36 |
+
contribution_vagueness: float
|
| 37 |
+
overcrowding_signals: int
|
| 38 |
+
saturation_score: float
|
| 39 |
+
risk_level: str
|
| 40 |
+
summary: str
|
| 41 |
+
flags: list
|
| 42 |
+
flags_count: int
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ββ Compiled patterns βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
|
| 47 |
+
_NOVELTY_CLAIMS = re.compile(
|
| 48 |
+
r'\b(novel|innovative|first|pioneer|unique|original|breakthrough|'
|
| 49 |
+
r'state.of.the.art|cutting.edge|unprecedented|revolutionar\w+|'
|
| 50 |
+
r'new approach|new method|new framework|we propose|we present|'
|
| 51 |
+
r'we introduce|we develop)\b',
|
| 52 |
+
re.IGNORECASE,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
_SATURATION_SIGNALS = re.compile(
|
| 56 |
+
r'\b(many studies|numerous studies|extensive research|'
|
| 57 |
+
r'widely studied|well.studied|well.known|well.established|'
|
| 58 |
+
r'extensively investigated|much attention|growing body|'
|
| 59 |
+
r'large body of|considerable research|substantial literature|'
|
| 60 |
+
r'abundant literature|intensively studied|heavily researched|'
|
| 61 |
+
r'significant amount of work|proliferation of|surge of interest)\b',
|
| 62 |
+
re.IGNORECASE,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
_VAGUE_CONTRIBUTION = re.compile(
|
| 66 |
+
r'\b(improve\w*|enhanc\w*|better\w*|outperform\w*|superior\w*|'
|
| 67 |
+
r'more efficient|more effective|more accurate|higher performance|'
|
| 68 |
+
r'significant improvement|notable improvement|'
|
| 69 |
+
r'promising results|competitive results)\b',
|
| 70 |
+
re.IGNORECASE,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
_KEYWORD_STOP = {
|
| 74 |
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to',
|
| 75 |
+
'for', 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were',
|
| 76 |
+
'be', 'been', 'has', 'have', 'had', 'do', 'does', 'did', 'will',
|
| 77 |
+
'would', 'could', 'should', 'may', 'might', 'this', 'that', 'these',
|
| 78 |
+
'those', 'it', 'its', 'we', 'our', 'their', 'as', 'not', 'also',
|
| 79 |
+
'can', 'which', 'such', 'than', 'other', 'into', 'used', 'using',
|
| 80 |
+
'based', 'paper', 'study', 'research', 'method', 'approach', 'work',
|
| 81 |
+
'show', 'shows', 'shown', 'propose', 'present', 'results', 'data',
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
_SENTENCE_SPLIT = re.compile(r'(?<=[.!?])\s+')
|
| 85 |
+
_WORD_RE = re.compile(r'\b[a-zA-Z]{4,}\b')
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ββ Engine ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
class FieldSaturationDetector:
|
| 91 |
+
"""
|
| 92 |
+
Field Saturation Detector β Module 23.
|
| 93 |
+
|
| 94 |
+
Analyses paper text to detect:
|
| 95 |
+
- Over-saturated research topics with redundant contributions
|
| 96 |
+
- Vague novelty claims unsupported by specific advances
|
| 97 |
+
- Crowding signals indicating field congestion
|
| 98 |
+
- Mismatch between claimed novelty and acknowledged saturation
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def analyze(self, text: str) -> SaturationResult:
|
| 102 |
+
text = (text or "").strip()
|
| 103 |
+
if not text:
|
| 104 |
+
return self._empty_result("No text provided for saturation analysis.")
|
| 105 |
+
|
| 106 |
+
keywords = self._extract_topic_keywords(text)
|
| 107 |
+
keyword_density = self._compute_keyword_density(text, keywords)
|
| 108 |
+
redundancy_score = self._compute_redundancy(text)
|
| 109 |
+
novelty_claim_score = self._compute_novelty_claims(text)
|
| 110 |
+
contribution_vague = self._compute_contribution_vagueness(text)
|
| 111 |
+
overcrowding_signals = self._count_overcrowding_signals(text)
|
| 112 |
+
|
| 113 |
+
saturation_score = self._compute_score(
|
| 114 |
+
keyword_density,
|
| 115 |
+
redundancy_score,
|
| 116 |
+
novelty_claim_score,
|
| 117 |
+
contribution_vague,
|
| 118 |
+
overcrowding_signals,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
risk_level = (
|
| 122 |
+
"critical" if saturation_score >= 0.75 else
|
| 123 |
+
"high" if saturation_score >= 0.55 else
|
| 124 |
+
"medium" if saturation_score >= 0.30 else
|
| 125 |
+
"low"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
flags = self._build_flags(
|
| 129 |
+
keywords, keyword_density, redundancy_score,
|
| 130 |
+
novelty_claim_score, contribution_vague,
|
| 131 |
+
overcrowding_signals, saturation_score,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
summary = self._build_summary(
|
| 135 |
+
keywords, overcrowding_signals,
|
| 136 |
+
redundancy_score, saturation_score, risk_level,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
return SaturationResult(
|
| 140 |
+
topic_keywords = keywords[:15],
|
| 141 |
+
keyword_density = round(keyword_density, 4),
|
| 142 |
+
redundancy_score = round(redundancy_score, 4),
|
| 143 |
+
novelty_claim_score = round(novelty_claim_score, 4),
|
| 144 |
+
contribution_vagueness = round(contribution_vague, 4),
|
| 145 |
+
overcrowding_signals = overcrowding_signals,
|
| 146 |
+
saturation_score = round(saturation_score, 4),
|
| 147 |
+
risk_level = risk_level,
|
| 148 |
+
summary = summary,
|
| 149 |
+
flags = flags,
|
| 150 |
+
flags_count = len(flags),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# ββ Extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
+
|
| 155 |
+
def _extract_topic_keywords(self, text: str) -> list:
|
| 156 |
+
words = _WORD_RE.findall(text.lower())
|
| 157 |
+
cleaned = [w for w in words if w not in _KEYWORD_STOP and len(w) >= 4]
|
| 158 |
+
counter = Counter(cleaned)
|
| 159 |
+
return [w for w, _ in counter.most_common(30) if counter[w] >= 2]
|
| 160 |
+
|
| 161 |
+
# ββ Analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
+
|
| 163 |
+
def _compute_keyword_density(self, text: str, keywords: list) -> float:
|
| 164 |
+
if not keywords:
|
| 165 |
+
return 0.0
|
| 166 |
+
words = _WORD_RE.findall(text.lower())
|
| 167 |
+
total_words = max(len(words), 1)
|
| 168 |
+
kw_set = set(keywords[:10])
|
| 169 |
+
kw_count = sum(1 for w in words if w in kw_set)
|
| 170 |
+
raw_density = kw_count / total_words
|
| 171 |
+
return round(min(1.0, raw_density * 8), 4)
|
| 172 |
+
|
| 173 |
+
def _compute_redundancy(self, text: str) -> float:
|
| 174 |
+
sentences = _SENTENCE_SPLIT.split(text.strip())
|
| 175 |
+
sentences = [s.strip() for s in sentences if len(s.strip()) > 20]
|
| 176 |
+
if len(sentences) < 4:
|
| 177 |
+
return 0.0
|
| 178 |
+
|
| 179 |
+
word_sets = []
|
| 180 |
+
for s in sentences:
|
| 181 |
+
words = set(_WORD_RE.findall(s.lower())) - _KEYWORD_STOP
|
| 182 |
+
if words:
|
| 183 |
+
word_sets.append(words)
|
| 184 |
+
|
| 185 |
+
if len(word_sets) < 4:
|
| 186 |
+
return 0.0
|
| 187 |
+
|
| 188 |
+
overlap_count = 0
|
| 189 |
+
total_pairs = 0
|
| 190 |
+
for i in range(len(word_sets)):
|
| 191 |
+
for j in range(i + 1, min(i + 6, len(word_sets))):
|
| 192 |
+
a, b = word_sets[i], word_sets[j]
|
| 193 |
+
union = a | b
|
| 194 |
+
intersection = a & b
|
| 195 |
+
if union:
|
| 196 |
+
jaccard = len(intersection) / len(union)
|
| 197 |
+
if jaccard > 0.35:
|
| 198 |
+
overlap_count += 1
|
| 199 |
+
total_pairs += 1
|
| 200 |
+
|
| 201 |
+
if total_pairs == 0:
|
| 202 |
+
return 0.0
|
| 203 |
+
return round(min(1.0, overlap_count / total_pairs * 2.5), 4)
|
| 204 |
+
|
| 205 |
+
def _compute_novelty_claims(self, text: str) -> float:
|
| 206 |
+
words = _WORD_RE.findall(text)
|
| 207 |
+
total_words = max(len(words), 1)
|
| 208 |
+
claim_count = len(_NOVELTY_CLAIMS.findall(text))
|
| 209 |
+
claim_ratio = claim_count / total_words
|
| 210 |
+
|
| 211 |
+
sat_count = len(_SATURATION_SIGNALS.findall(text))
|
| 212 |
+
|
| 213 |
+
if sat_count > 0 and claim_count > 0:
|
| 214 |
+
mismatch = min(1.0, (claim_count / max(sat_count, 1)) * 0.25)
|
| 215 |
+
else:
|
| 216 |
+
mismatch = 0.0
|
| 217 |
+
|
| 218 |
+
base = min(1.0, claim_ratio * 60)
|
| 219 |
+
return round(min(1.0, base * 0.5 + mismatch * 0.5), 4)
|
| 220 |
+
|
| 221 |
+
def _compute_contribution_vagueness(self, text: str) -> float:
|
| 222 |
+
words = _WORD_RE.findall(text)
|
| 223 |
+
total_words = max(len(words), 1)
|
| 224 |
+
vague_count = len(_VAGUE_CONTRIBUTION.findall(text))
|
| 225 |
+
return round(min(1.0, (vague_count / total_words) * 80), 4)
|
| 226 |
+
|
| 227 |
+
def _count_overcrowding_signals(self, text: str) -> int:
|
| 228 |
+
return len(_SATURATION_SIGNALS.findall(text))
|
| 229 |
+
|
| 230 |
+
# ββ Scoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
def _compute_score(
|
| 233 |
+
self,
|
| 234 |
+
keyword_density: float,
|
| 235 |
+
redundancy_score: float,
|
| 236 |
+
novelty_claim_score: float,
|
| 237 |
+
contribution_vague: float,
|
| 238 |
+
overcrowding: int,
|
| 239 |
+
) -> float:
|
| 240 |
+
score = 0.0
|
| 241 |
+
score += keyword_density * 0.15
|
| 242 |
+
score += redundancy_score * 0.30
|
| 243 |
+
score += novelty_claim_score * 0.25
|
| 244 |
+
score += contribution_vague * 0.20
|
| 245 |
+
score += min(overcrowding * 0.05, 0.20)
|
| 246 |
+
return round(min(1.0, score), 4)
|
| 247 |
+
|
| 248 |
+
# ββ Flags βββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββ
|
| 249 |
+
|
| 250 |
+
def _build_flags(
|
| 251 |
+
self,
|
| 252 |
+
keywords: list,
|
| 253 |
+
keyword_density: float,
|
| 254 |
+
redundancy_score: float,
|
| 255 |
+
novelty_claim_score: float,
|
| 256 |
+
contribution_vague: float,
|
| 257 |
+
overcrowding: int,
|
| 258 |
+
saturation_score: float,
|
| 259 |
+
) -> list:
|
| 260 |
+
flags = []
|
| 261 |
+
|
| 262 |
+
if overcrowding >= 3:
|
| 263 |
+
flags.append(SaturationFlag(
|
| 264 |
+
flag_type = "field_overcrowding_acknowledged",
|
| 265 |
+
severity = "high",
|
| 266 |
+
description = (
|
| 267 |
+
f"Paper acknowledges {overcrowding} field saturation signals, "
|
| 268 |
+
f"yet still claims novel contribution β a credibility mismatch."
|
| 269 |
+
),
|
| 270 |
+
evidence = (
|
| 271 |
+
f"{overcrowding} saturation phrases detected: "
|
| 272 |
+
f"'many studies', 'well-studied', 'extensive research', etc. "
|
| 273 |
+
f"Field is crowded by the paper's own admission."
|
| 274 |
+
),
|
| 275 |
+
suggestion = (
|
| 276 |
+
"Clearly articulate what specific gap this paper fills that "
|
| 277 |
+
"prior work has not. Vague novelty in saturated fields is a "
|
| 278 |
+
"major rejection signal for top journals."
|
| 279 |
+
),
|
| 280 |
+
))
|
| 281 |
+
|
| 282 |
+
if redundancy_score >= 0.45:
|
| 283 |
+
flags.append(SaturationFlag(
|
| 284 |
+
flag_type = "high_content_redundancy",
|
| 285 |
+
severity = "medium",
|
| 286 |
+
description = (
|
| 287 |
+
"High sentence-level content redundancy detected β paper "
|
| 288 |
+
"repeats concepts across sections without adding new information."
|
| 289 |
+
),
|
| 290 |
+
evidence = (
|
| 291 |
+
f"Jaccard similarity analysis across sentence windows "
|
| 292 |
+
f"returned redundancy score of {redundancy_score:.2f} "
|
| 293 |
+
f"(threshold: 0.45). Significant content overlap present."
|
| 294 |
+
),
|
| 295 |
+
suggestion = (
|
| 296 |
+
"Restructure paper to eliminate repetitive content. "
|
| 297 |
+
"Each section should introduce new information, not restate previous points."
|
| 298 |
+
),
|
| 299 |
+
))
|
| 300 |
+
|
| 301 |
+
if novelty_claim_score >= 0.45:
|
| 302 |
+
flags.append(SaturationFlag(
|
| 303 |
+
flag_type = "overclaimed_novelty",
|
| 304 |
+
severity = "high",
|
| 305 |
+
description = (
|
| 306 |
+
"Paper makes disproportionately high novelty claims relative "
|
| 307 |
+
"to the specificity of its actual contributions."
|
| 308 |
+
),
|
| 309 |
+
evidence = (
|
| 310 |
+
f"Novelty claim density score: {novelty_claim_score:.2f}. "
|
| 311 |
+
f"High frequency of terms like 'novel', 'first', 'innovative', "
|
| 312 |
+
f"'breakthrough' without commensurate technical specificity."
|
| 313 |
+
),
|
| 314 |
+
suggestion = (
|
| 315 |
+
"Replace broad novelty claims with precise technical statements. "
|
| 316 |
+
"Quantify what makes the contribution different, not just that it is different."
|
| 317 |
+
),
|
| 318 |
+
))
|
| 319 |
+
|
| 320 |
+
if contribution_vague >= 0.40:
|
| 321 |
+
flags.append(SaturationFlag(
|
| 322 |
+
flag_type = "vague_contribution_language",
|
| 323 |
+
severity = "medium",
|
| 324 |
+
description = (
|
| 325 |
+
"Contribution language is vague and non-specific β paper "
|
| 326 |
+
"relies on generic improvement claims rather than measurable advances."
|
| 327 |
+
),
|
| 328 |
+
evidence = (
|
| 329 |
+
f"Vagueness score: {contribution_vague:.2f}. Overuse of "
|
| 330 |
+
f"'improve', 'enhance', 'better', 'outperform' without "
|
| 331 |
+
f"quantified baselines or specific metrics."
|
| 332 |
+
),
|
| 333 |
+
suggestion = (
|
| 334 |
+
"State contributions with exact numbers: 'X% improvement on Y benchmark "
|
| 335 |
+
"over Z baseline' instead of 'improved performance'."
|
| 336 |
+
),
|
| 337 |
+
))
|
| 338 |
+
|
| 339 |
+
if keyword_density >= 0.55 and len(keywords) >= 5:
|
| 340 |
+
flags.append(SaturationFlag(
|
| 341 |
+
flag_type = "topic_keyword_saturation",
|
| 342 |
+
severity = "low",
|
| 343 |
+
description = (
|
| 344 |
+
"Topic keyword density is high β a small set of terms "
|
| 345 |
+
"dominates the entire paper, suggesting narrow scope."
|
| 346 |
+
),
|
| 347 |
+
evidence = (
|
| 348 |
+
f"Keyword density index: {keyword_density:.2f}. "
|
| 349 |
+
f"Top terms: {', '.join(keywords[:6])}. "
|
| 350 |
+
f"Repeated heavily throughout without broadening context."
|
| 351 |
+
),
|
| 352 |
+
suggestion = (
|
| 353 |
+
"Contextualize the work within broader research themes. "
|
| 354 |
+
"Narrow keyword scope can signal incremental rather than transformative contribution."
|
| 355 |
+
),
|
| 356 |
+
))
|
| 357 |
+
|
| 358 |
+
if not flags:
|
| 359 |
+
flags.append(SaturationFlag(
|
| 360 |
+
flag_type = "field_saturation_acceptable",
|
| 361 |
+
severity = "low",
|
| 362 |
+
description = "No significant field saturation indicators detected.",
|
| 363 |
+
evidence = (
|
| 364 |
+
f"Saturation score: {saturation_score:.2f}. "
|
| 365 |
+
f"Contribution language is sufficiently specific. "
|
| 366 |
+
f"Field crowding signals are within acceptable range."
|
| 367 |
+
),
|
| 368 |
+
suggestion = "Continue ensuring each section adds incremental specificity.",
|
| 369 |
+
))
|
| 370 |
+
|
| 371 |
+
return flags
|
| 372 |
+
|
| 373 |
+
# ββ Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 374 |
+
|
| 375 |
+
def _build_summary(
|
| 376 |
+
self,
|
| 377 |
+
keywords: list,
|
| 378 |
+
overcrowding: int,
|
| 379 |
+
redundancy_score: float,
|
| 380 |
+
saturation_score: float,
|
| 381 |
+
risk_level: str,
|
| 382 |
+
) -> str:
|
| 383 |
+
top_kw = ', '.join(keywords[:5]) if keywords else 'none detected'
|
| 384 |
+
return (
|
| 385 |
+
f"Field saturation analysis complete. "
|
| 386 |
+
f"Top topic keywords: {top_kw}. "
|
| 387 |
+
f"{overcrowding} field crowding signal(s) detected in text. "
|
| 388 |
+
f"Content redundancy score: {redundancy_score:.2f}. "
|
| 389 |
+
f"Overall saturation risk: {risk_level.upper()}."
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
# ββ Fallback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 393 |
+
|
| 394 |
+
def _empty_result(self, msg: str) -> SaturationResult:
|
| 395 |
+
return SaturationResult(
|
| 396 |
+
topic_keywords = [],
|
| 397 |
+
keyword_density = 0.0,
|
| 398 |
+
redundancy_score = 0.0,
|
| 399 |
+
novelty_claim_score = 0.0,
|
| 400 |
+
contribution_vagueness = 0.0,
|
| 401 |
+
overcrowding_signals = 0,
|
| 402 |
+
saturation_score = 0.0,
|
| 403 |
+
risk_level = "low",
|
| 404 |
+
summary = msg,
|
| 405 |
+
flags = [],
|
| 406 |
+
flags_count = 0,
|
| 407 |
+
)
|
src/scipeerai/modules/figure_forensics.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Figure Forensics Module
|
| 2 |
+
# -----------------------
|
| 3 |
+
# Scientific image manipulation is one of the hardest
|
| 4 |
+
# fraud types to catch manually. A reviewer comparing
|
| 5 |
+
# 40 gel images across a paper would need hours.
|
| 6 |
+
# We do it in milliseconds.
|
| 7 |
+
#
|
| 8 |
+
# Three things we check:
|
| 9 |
+
# 1. Duplicate/recycled figures (perceptual hashing)
|
| 10 |
+
# 2. Signs of digital editing (Error Level Analysis)
|
| 11 |
+
# 3. Unnatural brightness uniformity (contrast flattening)
|
| 12 |
+
|
| 13 |
+
import io
|
| 14 |
+
import math
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import fitz # PyMuPDF β extract images from PDF
|
| 19 |
+
import imagehash # perceptual hashing
|
| 20 |
+
import numpy as np
|
| 21 |
+
from PIL import Image, ImageFilter
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ββ data structures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class ExtractedFigure:
|
| 28 |
+
page_number: int
|
| 29 |
+
figure_index: int
|
| 30 |
+
width: int
|
| 31 |
+
height: int
|
| 32 |
+
image: Image.Image # actual PIL image object
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class ForensicFlag:
|
| 37 |
+
flag_type: str
|
| 38 |
+
severity: str
|
| 39 |
+
description: str
|
| 40 |
+
evidence: str
|
| 41 |
+
figures_involved: list
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class FigureForensicsResult:
|
| 46 |
+
figures_found: int
|
| 47 |
+
flags: list
|
| 48 |
+
duplicate_pairs: list # list of (fig_a, fig_b) index pairs
|
| 49 |
+
risk_score: float
|
| 50 |
+
risk_level: str
|
| 51 |
+
summary: str
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
|
| 56 |
+
class FigureForensicsEngine:
|
| 57 |
+
"""
|
| 58 |
+
Extracts figures from a PDF and runs forensic analysis on each one.
|
| 59 |
+
|
| 60 |
+
Why class-based: we'll want to tune sensitivity thresholds
|
| 61 |
+
per domain β medical imaging needs stricter checks than
|
| 62 |
+
social science bar charts.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
# two images with hash distance <= this are "suspiciously similar"
|
| 66 |
+
DUPLICATE_HASH_THRESHOLD = 8
|
| 67 |
+
|
| 68 |
+
# images smaller than this are likely icons/logos β skip them
|
| 69 |
+
MIN_FIGURE_SIZE = (50, 50)
|
| 70 |
+
|
| 71 |
+
def __init__(self):
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
+
# ββ public method βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
def analyze(self, pdf_path: str) -> FigureForensicsResult:
|
| 77 |
+
"""
|
| 78 |
+
Full forensic pipeline for a PDF file.
|
| 79 |
+
Extract β Hash β Compare β Analyze β Report
|
| 80 |
+
"""
|
| 81 |
+
pdf_path = Path(pdf_path)
|
| 82 |
+
if not pdf_path.exists():
|
| 83 |
+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
|
| 84 |
+
|
| 85 |
+
figures = self._extract_figures(pdf_path)
|
| 86 |
+
|
| 87 |
+
if not figures:
|
| 88 |
+
return FigureForensicsResult(
|
| 89 |
+
figures_found=0,
|
| 90 |
+
flags=[],
|
| 91 |
+
duplicate_pairs=[],
|
| 92 |
+
risk_score=0.0,
|
| 93 |
+
risk_level="low",
|
| 94 |
+
summary="No figures found in this document.",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
flags = []
|
| 98 |
+
duplicate_pairs = []
|
| 99 |
+
|
| 100 |
+
dup_flags, dup_pairs = self._check_duplicates(figures)
|
| 101 |
+
flags.extend(dup_flags)
|
| 102 |
+
duplicate_pairs.extend(dup_pairs)
|
| 103 |
+
|
| 104 |
+
ela_flags = self._check_ela_anomalies(figures)
|
| 105 |
+
flags.extend(ela_flags)
|
| 106 |
+
|
| 107 |
+
brightness_flags = self._check_brightness_uniformity(figures)
|
| 108 |
+
flags.extend(brightness_flags)
|
| 109 |
+
|
| 110 |
+
risk_score = self._calculate_risk(flags)
|
| 111 |
+
risk_level = self._get_risk_level(risk_score)
|
| 112 |
+
|
| 113 |
+
return FigureForensicsResult(
|
| 114 |
+
figures_found=len(figures),
|
| 115 |
+
flags=flags,
|
| 116 |
+
duplicate_pairs=duplicate_pairs,
|
| 117 |
+
risk_score=round(risk_score, 3),
|
| 118 |
+
risk_level=risk_level,
|
| 119 |
+
summary=self._write_summary(len(figures), flags, risk_level),
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# ββ extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
+
|
| 124 |
+
def _extract_figures(self, pdf_path: Path) -> list:
|
| 125 |
+
"""
|
| 126 |
+
Pull every image out of the PDF, skip tiny ones
|
| 127 |
+
that are probably decorative elements.
|
| 128 |
+
"""
|
| 129 |
+
figures = []
|
| 130 |
+
doc = fitz.open(str(pdf_path))
|
| 131 |
+
|
| 132 |
+
for page_num, page in enumerate(doc):
|
| 133 |
+
image_list = page.get_images(full=True)
|
| 134 |
+
|
| 135 |
+
for img_idx, img_ref in enumerate(image_list):
|
| 136 |
+
xref = img_ref[0]
|
| 137 |
+
try:
|
| 138 |
+
base_image = doc.extract_image(xref)
|
| 139 |
+
img_bytes = base_image["image"]
|
| 140 |
+
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 141 |
+
|
| 142 |
+
# skip tiny decorative images
|
| 143 |
+
if img.width < self.MIN_FIGURE_SIZE[0]:
|
| 144 |
+
continue
|
| 145 |
+
if img.height < self.MIN_FIGURE_SIZE[1]:
|
| 146 |
+
continue
|
| 147 |
+
|
| 148 |
+
figures.append(ExtractedFigure(
|
| 149 |
+
page_number=page_num + 1,
|
| 150 |
+
figure_index=len(figures),
|
| 151 |
+
width=img.width,
|
| 152 |
+
height=img.height,
|
| 153 |
+
image=img,
|
| 154 |
+
))
|
| 155 |
+
|
| 156 |
+
except Exception:
|
| 157 |
+
# corrupted or unreadable image β skip, don't crash
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
doc.close()
|
| 161 |
+
return figures
|
| 162 |
+
|
| 163 |
+
# ββ forensic checks βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
|
| 165 |
+
def _check_duplicates(self, figures: list) -> tuple:
|
| 166 |
+
"""
|
| 167 |
+
Perceptual hashing β convert each image to a 64-bit hash
|
| 168 |
+
that represents its visual "fingerprint."
|
| 169 |
+
|
| 170 |
+
Unlike MD5 (which changes completely with one pixel edit),
|
| 171 |
+
perceptual hash stays similar if images are visually similar.
|
| 172 |
+
This catches: same image re-saved at different quality,
|
| 173 |
+
cropped versions, brightness-adjusted copies.
|
| 174 |
+
"""
|
| 175 |
+
flags = []
|
| 176 |
+
duplicate_pairs = []
|
| 177 |
+
|
| 178 |
+
# compute hash for every figure
|
| 179 |
+
hashes = []
|
| 180 |
+
for fig in figures:
|
| 181 |
+
h = imagehash.phash(fig.image)
|
| 182 |
+
hashes.append(h)
|
| 183 |
+
|
| 184 |
+
# compare every pair β O(nΒ²) but papers rarely have >50 figures
|
| 185 |
+
for i in range(len(figures)):
|
| 186 |
+
for j in range(i + 1, len(figures)):
|
| 187 |
+
distance = hashes[i] - hashes[j]
|
| 188 |
+
|
| 189 |
+
if distance <= self.DUPLICATE_HASH_THRESHOLD:
|
| 190 |
+
pair = (figures[i].figure_index, figures[j].figure_index)
|
| 191 |
+
duplicate_pairs.append(pair)
|
| 192 |
+
|
| 193 |
+
severity = "high" if distance <= 4 else "medium"
|
| 194 |
+
flags.append(ForensicFlag(
|
| 195 |
+
flag_type="duplicate_figures",
|
| 196 |
+
severity=severity,
|
| 197 |
+
description=(
|
| 198 |
+
f"Figure on page {figures[i].page_number} and "
|
| 199 |
+
f"figure on page {figures[j].page_number} are "
|
| 200 |
+
f"visually identical or near-identical "
|
| 201 |
+
f"(hash distance: {distance}/64)."
|
| 202 |
+
),
|
| 203 |
+
evidence=f"Hash distance: {distance}. Threshold: {self.DUPLICATE_HASH_THRESHOLD}",
|
| 204 |
+
figures_involved=[
|
| 205 |
+
figures[i].figure_index,
|
| 206 |
+
figures[j].figure_index
|
| 207 |
+
],
|
| 208 |
+
))
|
| 209 |
+
|
| 210 |
+
return flags, duplicate_pairs
|
| 211 |
+
|
| 212 |
+
def _check_ela_anomalies(self, figures: list) -> list:
|
| 213 |
+
"""
|
| 214 |
+
Error Level Analysis (ELA) β when an image is edited and
|
| 215 |
+
re-saved as JPEG, the edited regions compress differently
|
| 216 |
+
from the original. This creates visible "error level" patterns.
|
| 217 |
+
|
| 218 |
+
High variance in ELA output = suspicious editing.
|
| 219 |
+
"""
|
| 220 |
+
flags = []
|
| 221 |
+
|
| 222 |
+
for fig in figures:
|
| 223 |
+
ela_score = self._compute_ela_score(fig.image)
|
| 224 |
+
|
| 225 |
+
if ela_score > 35.0:
|
| 226 |
+
flags.append(ForensicFlag(
|
| 227 |
+
flag_type="ela_anomaly",
|
| 228 |
+
severity="high" if ela_score > 50 else "medium",
|
| 229 |
+
description=(
|
| 230 |
+
f"Figure on page {fig.page_number} shows unusual "
|
| 231 |
+
f"compression artifacts consistent with digital editing. "
|
| 232 |
+
f"ELA score: {round(ela_score, 2)}"
|
| 233 |
+
),
|
| 234 |
+
evidence=f"ELA variance score: {round(ela_score, 2)} (threshold: 35.0)",
|
| 235 |
+
figures_involved=[fig.figure_index],
|
| 236 |
+
))
|
| 237 |
+
|
| 238 |
+
return flags
|
| 239 |
+
|
| 240 |
+
def _check_brightness_uniformity(self, figures: list) -> list:
|
| 241 |
+
"""
|
| 242 |
+
Legitimately captured images (microscopy, gels, photos)
|
| 243 |
+
have natural brightness variation. An image with extremely
|
| 244 |
+
uniform brightness across all regions suggests artificial
|
| 245 |
+
contrast adjustment or digital fabrication.
|
| 246 |
+
"""
|
| 247 |
+
flags = []
|
| 248 |
+
|
| 249 |
+
for fig in figures:
|
| 250 |
+
uniformity_score = self._compute_brightness_uniformity(fig.image)
|
| 251 |
+
|
| 252 |
+
# very high uniformity = suspiciously "perfect" image
|
| 253 |
+
if uniformity_score > 0.92:
|
| 254 |
+
flags.append(ForensicFlag(
|
| 255 |
+
flag_type="unnatural_brightness_uniformity",
|
| 256 |
+
severity="medium",
|
| 257 |
+
description=(
|
| 258 |
+
f"Figure on page {fig.page_number} has unusually "
|
| 259 |
+
f"uniform brightness distribution "
|
| 260 |
+
f"(uniformity: {round(uniformity_score * 100, 1)}%). "
|
| 261 |
+
f"Natural images rarely exceed 85% uniformity."
|
| 262 |
+
),
|
| 263 |
+
evidence=f"Uniformity score: {round(uniformity_score, 4)}",
|
| 264 |
+
figures_involved=[fig.figure_index],
|
| 265 |
+
))
|
| 266 |
+
|
| 267 |
+
return flags
|
| 268 |
+
|
| 269 |
+
# ββ computation helpers ββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββ
|
| 270 |
+
|
| 271 |
+
def _compute_ela_score(self, image: Image.Image) -> float:
|
| 272 |
+
"""
|
| 273 |
+
Save image at low quality, compare to original.
|
| 274 |
+
Edited regions show higher difference = higher ELA score.
|
| 275 |
+
"""
|
| 276 |
+
# save at low quality to JPEG (amplifies compression artifacts)
|
| 277 |
+
buffer = io.BytesIO()
|
| 278 |
+
image.save(buffer, format="JPEG", quality=75)
|
| 279 |
+
buffer.seek(0)
|
| 280 |
+
compressed = Image.open(buffer).convert("RGB")
|
| 281 |
+
|
| 282 |
+
# pixel-wise difference
|
| 283 |
+
orig_arr = np.array(image, dtype=np.float32)
|
| 284 |
+
comp_arr = np.array(compressed, dtype=np.float32)
|
| 285 |
+
diff = np.abs(orig_arr - comp_arr)
|
| 286 |
+
|
| 287 |
+
# standard deviation of the difference β high = suspicious
|
| 288 |
+
return float(np.std(diff))
|
| 289 |
+
|
| 290 |
+
def _compute_brightness_uniformity(self, image: Image.Image) -> float:
|
| 291 |
+
"""
|
| 292 |
+
Convert to grayscale, measure how "flat" the histogram is.
|
| 293 |
+
A very flat histogram = artificially processed image.
|
| 294 |
+
"""
|
| 295 |
+
gray = np.array(image.convert("L"), dtype=np.float32)
|
| 296 |
+
std_dev = np.std(gray)
|
| 297 |
+
|
| 298 |
+
# normalize: low std_dev = high uniformity score
|
| 299 |
+
# 128 is half of 255 β a natural image usually has std > 40
|
| 300 |
+
uniformity = 1.0 - min(std_dev / 128.0, 1.0)
|
| 301 |
+
return float(uniformity)
|
| 302 |
+
|
| 303 |
+
# ββ scoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 304 |
+
|
| 305 |
+
def _calculate_risk(self, flags: list) -> float:
|
| 306 |
+
weights = {"high": 0.40, "medium": 0.20, "low": 0.08}
|
| 307 |
+
score = sum(weights.get(f.severity, 0) for f in flags)
|
| 308 |
+
return min(score, 1.0)
|
| 309 |
+
|
| 310 |
+
def _get_risk_level(self, score: float) -> str:
|
| 311 |
+
if score >= 0.7:
|
| 312 |
+
return "critical"
|
| 313 |
+
elif score >= 0.4:
|
| 314 |
+
return "high"
|
| 315 |
+
elif score >= 0.2:
|
| 316 |
+
return "medium"
|
| 317 |
+
return "low"
|
| 318 |
+
|
| 319 |
+
def _write_summary(self, fig_count: int, flags: list, risk_level: str) -> str:
|
| 320 |
+
if not flags:
|
| 321 |
+
return (
|
| 322 |
+
f"Analyzed {fig_count} figure(s). "
|
| 323 |
+
f"No forensic anomalies detected."
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 327 |
+
med = sum(1 for f in flags if f.severity == "medium")
|
| 328 |
+
parts = []
|
| 329 |
+
if high:
|
| 330 |
+
parts.append(f"{high} high-severity issue{'s' if high > 1 else ''}")
|
| 331 |
+
if med:
|
| 332 |
+
parts.append(f"{med} medium-severity concern{'s' if med > 1 else ''}")
|
| 333 |
+
|
| 334 |
+
return (
|
| 335 |
+
f"Analyzed {fig_count} figure(s). "
|
| 336 |
+
f"Figure forensics flagged {', '.join(parts)}. "
|
| 337 |
+
f"Risk level: {risk_level.upper()}."
|
| 338 |
+
)
|
src/scipeerai/modules/fraud_fingerprint.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import List, Dict, Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# ββ Section markers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 7 |
+
|
| 8 |
+
SECTION_MARKERS = [
|
| 9 |
+
"abstract", "introduction", "background", "related work",
|
| 10 |
+
"literature review", "methodology", "methods", "materials and methods",
|
| 11 |
+
"experimental setup", "results", "findings", "discussion",
|
| 12 |
+
"conclusion", "conclusions", "references", "acknowledgment",
|
| 13 |
+
"acknowledgements",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
EXPECTED_ORDER = [
|
| 17 |
+
"abstract", "introduction", "background", "related work",
|
| 18 |
+
"literature review", "methodology", "methods", "materials and methods",
|
| 19 |
+
"results", "findings", "discussion", "conclusion", "conclusions",
|
| 20 |
+
"references",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
# ββ Style signal patterns βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
|
| 25 |
+
PASSIVE_PAT = re.compile(
|
| 26 |
+
r'\b(?:was|were|is|are|been|being)\s+\w+ed\b',
|
| 27 |
+
re.IGNORECASE,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
HEDGE_WORDS = [
|
| 31 |
+
"may", "might", "could", "possibly", "perhaps", "suggest",
|
| 32 |
+
"appear", "seem", "likely", "probably", "approximately",
|
| 33 |
+
"indicate", "potential", "tend to",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
CITATION_PAT = re.compile(
|
| 37 |
+
r'\[\d+\]|\(\w+,?\s*\d{4}\)|\b\w+\s+\d{4}\b',
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
JARGON_PAT = re.compile(
|
| 41 |
+
r'\b(?:therefore|furthermore|moreover|however|nevertheless'
|
| 42 |
+
r'|consequently|subsequently|notably|specifically|particularly'
|
| 43 |
+
r'|importantly|significantly|substantially)\b',
|
| 44 |
+
re.IGNORECASE,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
AI_PHRASE_PAT = re.compile(
|
| 48 |
+
r'\b(?:it is worth noting|in conclusion|furthermore|notably'
|
| 49 |
+
r'|it should be noted|taken together|in summary|shed(?:s)? light'
|
| 50 |
+
r'|delve|leverage|utilize|facilitate|groundbreaking|unprecedented'
|
| 51 |
+
r'|robust|comprehensive|state-of-the-art|cutting-edge)\b',
|
| 52 |
+
re.IGNORECASE,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
AUTHORSHIP_SIGNALS = re.compile(
|
| 56 |
+
r'\b(?:the author|the authors|we|our|I\b)',
|
| 57 |
+
re.IGNORECASE,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
TEMPLATE_PHRASES = [
|
| 61 |
+
"this paper presents", "in this paper", "this study aims",
|
| 62 |
+
"the main contribution", "to the best of our knowledge",
|
| 63 |
+
"this work proposes", "we propose a novel", "extensive experiments",
|
| 64 |
+
"state of the art", "outperforms existing", "superior performance",
|
| 65 |
+
"promising results", "significantly better", "we demonstrate that",
|
| 66 |
+
"our approach achieves", "our method achieves",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ββ Data classes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class FraudFlag:
|
| 74 |
+
flag_type: str
|
| 75 |
+
severity: str
|
| 76 |
+
description: str
|
| 77 |
+
evidence: str
|
| 78 |
+
suggestion: str
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class FraudFingerprintResult:
|
| 83 |
+
fingerprint_score: float
|
| 84 |
+
risk_level: str
|
| 85 |
+
summary: str
|
| 86 |
+
section_dna: Dict[str, dict]
|
| 87 |
+
anomalies: List[dict]
|
| 88 |
+
flags: List[FraudFlag] = field(default_factory=list)
|
| 89 |
+
flags_count: int = 0
|
| 90 |
+
ai_phrase_count: int = 0
|
| 91 |
+
style_consistency: float = 0.0
|
| 92 |
+
authorship_consistency: float = 0.0
|
| 93 |
+
section_order_correct: bool = True
|
| 94 |
+
template_score: float = 0.0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ββ Main class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
class FraudFingerprinter:
|
| 100 |
+
"""
|
| 101 |
+
Fraud Fingerprinting v2.3.4
|
| 102 |
+
|
| 103 |
+
Detects writing style inconsistencies across paper sections.
|
| 104 |
+
Ghost-writing, multi-author style mixing, and AI-generated
|
| 105 |
+
section substitution all produce detectable fingerprint anomalies.
|
| 106 |
+
|
| 107 |
+
v2.3.4 upgrades:
|
| 108 |
+
- 8 style DNA features (was 5)
|
| 109 |
+
- Coefficient of variation scoring β works on short text too
|
| 110 |
+
- Z-score per-section outlier detection
|
| 111 |
+
- AI phrase density integrated into fingerprint score
|
| 112 |
+
- _extract_authorship_signals preserved
|
| 113 |
+
- _check_section_order preserved
|
| 114 |
+
- _detect_template_writing preserved
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
def analyze(self, text: str, sections: Optional[dict] = None) -> FraudFingerprintResult:
|
| 118 |
+
# Split into sections if not pre-split
|
| 119 |
+
if not sections:
|
| 120 |
+
sections = self._split_sections(text)
|
| 121 |
+
|
| 122 |
+
# Fallback β treat whole text as one section
|
| 123 |
+
if not sections:
|
| 124 |
+
sections = {"full_text": text}
|
| 125 |
+
|
| 126 |
+
# Compute style DNA per section
|
| 127 |
+
dna_map = {}
|
| 128 |
+
for sec_name, sec_text in sections.items():
|
| 129 |
+
if sec_text and len(sec_text.strip()) > 20:
|
| 130 |
+
dna_map[sec_name] = self._compute_style_dna(sec_text)
|
| 131 |
+
|
| 132 |
+
# Compare sections for inconsistency
|
| 133 |
+
inconsistency_score, anomalies = self._compare_sections(dna_map)
|
| 134 |
+
|
| 135 |
+
# Authorship signals
|
| 136 |
+
authorship_score = self._extract_authorship_signals(text, sections)
|
| 137 |
+
|
| 138 |
+
# Section order check
|
| 139 |
+
order_correct, order_flags = self._check_section_order(sections)
|
| 140 |
+
|
| 141 |
+
# Template writing detection
|
| 142 |
+
template_score, template_phrases_found = self._detect_template_writing(text)
|
| 143 |
+
|
| 144 |
+
# AI phrase analysis on full text
|
| 145 |
+
ai_count = len(AI_PHRASE_PAT.findall(text))
|
| 146 |
+
words = text.split()
|
| 147 |
+
n_words = max(len(words), 1)
|
| 148 |
+
ai_density = ai_count / n_words
|
| 149 |
+
|
| 150 |
+
# Combined fingerprint score
|
| 151 |
+
ai_component = min(ai_density * 12, 0.40)
|
| 152 |
+
template_comp = min(template_score * 0.15, 0.15)
|
| 153 |
+
fingerprint_score = min(
|
| 154 |
+
inconsistency_score * 0.55
|
| 155 |
+
+ ai_component * 0.25
|
| 156 |
+
+ template_comp * 0.10
|
| 157 |
+
+ (0.10 if not order_correct else 0.0),
|
| 158 |
+
1.0
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Flags
|
| 162 |
+
flags: List[FraudFlag] = []
|
| 163 |
+
|
| 164 |
+
if inconsistency_score >= 0.45:
|
| 165 |
+
flags.append(FraudFlag(
|
| 166 |
+
flag_type = "style_inconsistency",
|
| 167 |
+
severity = "high" if inconsistency_score >= 0.65 else "medium",
|
| 168 |
+
description = (
|
| 169 |
+
f"Writing style varies significantly across sections "
|
| 170 |
+
f"(inconsistency score: {round(inconsistency_score * 100)}%). "
|
| 171 |
+
f"This pattern is consistent with ghost-writing, "
|
| 172 |
+
f"multi-author section contributions without harmonization, "
|
| 173 |
+
f"or AI-generated section substitution."
|
| 174 |
+
),
|
| 175 |
+
evidence = (
|
| 176 |
+
f"{len(anomalies)} section-level anomalies detected: "
|
| 177 |
+
+ (", ".join(
|
| 178 |
+
f"{a['section']}.{a['feature']} (CV={a['cv']})"
|
| 179 |
+
for a in anomalies[:3]
|
| 180 |
+
) if anomalies else "style drift across sections")
|
| 181 |
+
),
|
| 182 |
+
suggestion = (
|
| 183 |
+
"Review sections for stylistic consistency. "
|
| 184 |
+
"Large vocabulary or sentence-length differences between "
|
| 185 |
+
"sections may indicate undisclosed multiple authors or "
|
| 186 |
+
"AI-assisted writing."
|
| 187 |
+
),
|
| 188 |
+
))
|
| 189 |
+
|
| 190 |
+
if ai_count >= 5:
|
| 191 |
+
flags.append(FraudFlag(
|
| 192 |
+
flag_type = "ai_phrase_density",
|
| 193 |
+
severity = "high" if ai_count >= 10 else "medium",
|
| 194 |
+
description = (
|
| 195 |
+
f"High density of AI-typical phrases detected "
|
| 196 |
+
f"({ai_count} phrases). "
|
| 197 |
+
f"Pattern consistent with LLM-generated or heavily "
|
| 198 |
+
f"LLM-edited content."
|
| 199 |
+
),
|
| 200 |
+
evidence = (
|
| 201 |
+
f"AI phrase count: {ai_count} | "
|
| 202 |
+
f"Density: {round(ai_density * 1000, 1)} per 1000 words"
|
| 203 |
+
),
|
| 204 |
+
suggestion = (
|
| 205 |
+
"Review for AI-generated content. Journals increasingly "
|
| 206 |
+
"require disclosure of AI writing assistance."
|
| 207 |
+
),
|
| 208 |
+
))
|
| 209 |
+
|
| 210 |
+
if not order_correct and order_flags:
|
| 211 |
+
flags.append(FraudFlag(
|
| 212 |
+
flag_type = "section_order_anomaly",
|
| 213 |
+
severity = "low",
|
| 214 |
+
description = (
|
| 215 |
+
"Paper sections appear in non-standard order. "
|
| 216 |
+
"This may indicate copy-paste assembly or structural manipulation."
|
| 217 |
+
),
|
| 218 |
+
evidence = "; ".join(order_flags[:3]),
|
| 219 |
+
suggestion = (
|
| 220 |
+
"Verify that sections follow standard academic structure: "
|
| 221 |
+
"Abstract β Introduction β Methods β Results β Discussion β Conclusion."
|
| 222 |
+
),
|
| 223 |
+
))
|
| 224 |
+
|
| 225 |
+
if template_score >= 0.30:
|
| 226 |
+
flags.append(FraudFlag(
|
| 227 |
+
flag_type = "template_writing_detected",
|
| 228 |
+
severity = "medium" if template_score >= 0.50 else "low",
|
| 229 |
+
description = (
|
| 230 |
+
f"High density of template/boilerplate phrases detected "
|
| 231 |
+
f"(score: {round(template_score * 100)}%). "
|
| 232 |
+
f"Pattern associated with paper mills and AI-assisted writing."
|
| 233 |
+
),
|
| 234 |
+
evidence = (
|
| 235 |
+
f"Template phrases found: "
|
| 236 |
+
f"{', '.join(template_phrases_found[:5])}"
|
| 237 |
+
),
|
| 238 |
+
suggestion = (
|
| 239 |
+
"Replace generic template phrases with specific, "
|
| 240 |
+
"original descriptions of your methodology and findings."
|
| 241 |
+
),
|
| 242 |
+
))
|
| 243 |
+
|
| 244 |
+
if anomalies:
|
| 245 |
+
high_anom = [a for a in anomalies if a.get("severity") == "high"]
|
| 246 |
+
if high_anom:
|
| 247 |
+
flags.append(FraudFlag(
|
| 248 |
+
flag_type = "section_outlier",
|
| 249 |
+
severity = "medium",
|
| 250 |
+
description = (
|
| 251 |
+
f"{len(high_anom)} section(s) show extreme style deviation "
|
| 252 |
+
f"from the rest of the paper."
|
| 253 |
+
),
|
| 254 |
+
evidence = (
|
| 255 |
+
"; ".join(
|
| 256 |
+
f"Section '{a['section']}' has {a['feature']}="
|
| 257 |
+
f"{a['value']} vs paper mean {a['mean']}"
|
| 258 |
+
for a in high_anom[:2]
|
| 259 |
+
)
|
| 260 |
+
),
|
| 261 |
+
suggestion = (
|
| 262 |
+
"Inspect flagged sections for authorship consistency. "
|
| 263 |
+
"Outlier sections may have been written by a different person "
|
| 264 |
+
"or generated by AI."
|
| 265 |
+
),
|
| 266 |
+
))
|
| 267 |
+
|
| 268 |
+
# Risk level
|
| 269 |
+
risk_level = self._get_risk_level(fingerprint_score)
|
| 270 |
+
|
| 271 |
+
# Summary
|
| 272 |
+
summary = self._build_summary(
|
| 273 |
+
fingerprint_score, risk_level,
|
| 274 |
+
len(anomalies), ai_count, flags
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
return FraudFingerprintResult(
|
| 278 |
+
fingerprint_score = round(fingerprint_score, 4),
|
| 279 |
+
risk_level = risk_level,
|
| 280 |
+
summary = summary,
|
| 281 |
+
section_dna = dna_map,
|
| 282 |
+
anomalies = anomalies,
|
| 283 |
+
flags = flags,
|
| 284 |
+
flags_count = len(flags),
|
| 285 |
+
ai_phrase_count = ai_count,
|
| 286 |
+
style_consistency = round(1.0 - inconsistency_score, 4),
|
| 287 |
+
authorship_consistency = round(authorship_score, 4),
|
| 288 |
+
section_order_correct = order_correct,
|
| 289 |
+
template_score = round(template_score, 4),
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# ββ Private helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
|
| 294 |
+
def _split_sections(self, text: str) -> dict:
|
| 295 |
+
"""Split paper text into named sections."""
|
| 296 |
+
sections = {}
|
| 297 |
+
text_lower = text.lower()
|
| 298 |
+
|
| 299 |
+
for i, marker in enumerate(SECTION_MARKERS):
|
| 300 |
+
start = text_lower.find(marker)
|
| 301 |
+
if start == -1:
|
| 302 |
+
continue
|
| 303 |
+
end = len(text)
|
| 304 |
+
for next_marker in SECTION_MARKERS[i + 1:]:
|
| 305 |
+
next_idx = text_lower.find(next_marker, start + len(marker))
|
| 306 |
+
if next_idx != -1:
|
| 307 |
+
end = next_idx
|
| 308 |
+
break
|
| 309 |
+
section_text = text[start:end].strip()
|
| 310 |
+
if len(section_text) > 30:
|
| 311 |
+
sections[marker] = section_text
|
| 312 |
+
|
| 313 |
+
return sections
|
| 314 |
+
|
| 315 |
+
def _compute_style_dna(self, text: str) -> dict:
|
| 316 |
+
"""
|
| 317 |
+
v2.3.4 β 8-feature style DNA.
|
| 318 |
+
Works on both short and long text sections.
|
| 319 |
+
"""
|
| 320 |
+
if not text or len(text.strip()) < 10:
|
| 321 |
+
return {}
|
| 322 |
+
|
| 323 |
+
words = text.split()
|
| 324 |
+
sentences = [s.strip() for s in re.split(r'[.!?]+', text) if len(s.strip()) > 5]
|
| 325 |
+
n_words = max(len(words), 1)
|
| 326 |
+
n_sents = max(len(sentences), 1)
|
| 327 |
+
|
| 328 |
+
avg_sent_len = n_words / n_sents
|
| 329 |
+
vocab_diversity = len(set(w.lower() for w in words)) / n_words
|
| 330 |
+
passive_count = len(PASSIVE_PAT.findall(text))
|
| 331 |
+
passive_ratio = passive_count / n_sents
|
| 332 |
+
hedge_count = sum(1 for w in HEDGE_WORDS if w in text.lower())
|
| 333 |
+
hedge_ratio = hedge_count / n_words
|
| 334 |
+
cite_count = len(CITATION_PAT.findall(text))
|
| 335 |
+
citation_density = cite_count / n_words
|
| 336 |
+
jargon_count = len(JARGON_PAT.findall(text))
|
| 337 |
+
jargon_ratio = jargon_count / n_words
|
| 338 |
+
question_ratio = text.count('?') / n_sents
|
| 339 |
+
numbers = re.findall(r'\b\d+\.?\d*\b', text)
|
| 340 |
+
number_density = len(numbers) / n_words
|
| 341 |
+
|
| 342 |
+
return {
|
| 343 |
+
"avg_sentence_len": round(avg_sent_len, 3),
|
| 344 |
+
"vocab_diversity": round(vocab_diversity, 3),
|
| 345 |
+
"passive_ratio": round(passive_ratio, 3),
|
| 346 |
+
"hedge_ratio": round(hedge_ratio, 4),
|
| 347 |
+
"citation_density": round(citation_density, 4),
|
| 348 |
+
"jargon_ratio": round(jargon_ratio, 4),
|
| 349 |
+
"question_ratio": round(question_ratio, 4),
|
| 350 |
+
"number_density": round(number_density, 4),
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
def _compare_sections(self, dna_map: dict) -> tuple:
|
| 354 |
+
"""
|
| 355 |
+
v2.3.4 β Coefficient of variation scoring.
|
| 356 |
+
Works on both short and long sections.
|
| 357 |
+
"""
|
| 358 |
+
sections = {k: v for k, v in dna_map.items() if v}
|
| 359 |
+
if len(sections) < 2:
|
| 360 |
+
return 0.0, []
|
| 361 |
+
|
| 362 |
+
features = [
|
| 363 |
+
"avg_sentence_len", "vocab_diversity", "passive_ratio",
|
| 364 |
+
"hedge_ratio", "citation_density", "jargon_ratio",
|
| 365 |
+
"number_density",
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
anomalies = []
|
| 369 |
+
feature_scores = []
|
| 370 |
+
|
| 371 |
+
for feat in features:
|
| 372 |
+
vals = [
|
| 373 |
+
dna[feat] for dna in sections.values()
|
| 374 |
+
if feat in dna and dna[feat] is not None
|
| 375 |
+
]
|
| 376 |
+
if len(vals) < 2:
|
| 377 |
+
continue
|
| 378 |
+
|
| 379 |
+
mean_val = sum(vals) / len(vals)
|
| 380 |
+
if mean_val == 0:
|
| 381 |
+
continue
|
| 382 |
+
|
| 383 |
+
std_val = (sum((v - mean_val) ** 2 for v in vals) / len(vals)) ** 0.5
|
| 384 |
+
cv = std_val / (mean_val + 1e-9)
|
| 385 |
+
feature_scores.append(min(cv, 1.0))
|
| 386 |
+
|
| 387 |
+
if cv > 0.35:
|
| 388 |
+
for sec_name, dna in sections.items():
|
| 389 |
+
if feat not in dna:
|
| 390 |
+
continue
|
| 391 |
+
val = dna[feat]
|
| 392 |
+
z_approx = abs(val - mean_val) / (std_val + 1e-9)
|
| 393 |
+
if z_approx > 1.5:
|
| 394 |
+
anomalies.append({
|
| 395 |
+
"section": sec_name,
|
| 396 |
+
"feature": feat,
|
| 397 |
+
"value": round(val, 4),
|
| 398 |
+
"mean": round(mean_val, 4),
|
| 399 |
+
"cv": round(cv, 3),
|
| 400 |
+
"severity": "high" if cv > 0.60 else "medium",
|
| 401 |
+
})
|
| 402 |
+
|
| 403 |
+
if not feature_scores:
|
| 404 |
+
return 0.0, []
|
| 405 |
+
|
| 406 |
+
high_cv_count = sum(1 for s in feature_scores if s > 0.60)
|
| 407 |
+
med_cv_count = sum(1 for s in feature_scores if 0.35 < s <= 0.60)
|
| 408 |
+
base_score = sum(feature_scores) / len(feature_scores)
|
| 409 |
+
bonus = high_cv_count * 0.12 + med_cv_count * 0.05
|
| 410 |
+
final_score = min(base_score + bonus, 1.0)
|
| 411 |
+
|
| 412 |
+
return round(final_score, 4), anomalies
|
| 413 |
+
|
| 414 |
+
def _extract_authorship_signals(self, text: str, sections: dict) -> float:
|
| 415 |
+
"""
|
| 416 |
+
Detect authorship inconsistencies across sections.
|
| 417 |
+
Shifts in first-person vs third-person usage suggest
|
| 418 |
+
different authors wrote different sections.
|
| 419 |
+
"""
|
| 420 |
+
if not sections or len(sections) < 2:
|
| 421 |
+
return 1.0
|
| 422 |
+
|
| 423 |
+
section_scores = {}
|
| 424 |
+
for sec_name, sec_text in sections.items():
|
| 425 |
+
if not sec_text:
|
| 426 |
+
continue
|
| 427 |
+
words = sec_text.split()
|
| 428 |
+
n_words = max(len(words), 1)
|
| 429 |
+
we_count = len(re.findall(r'\bwe\b|\bour\b', sec_text, re.IGNORECASE))
|
| 430 |
+
i_count = len(re.findall(r'\bI\b', sec_text))
|
| 431 |
+
the_author = len(re.findall(r'\bthe\s+authors?\b', sec_text, re.IGNORECASE))
|
| 432 |
+
first_person = (we_count + i_count) / n_words
|
| 433 |
+
third_person = the_author / n_words
|
| 434 |
+
section_scores[sec_name] = {
|
| 435 |
+
"first_person": first_person,
|
| 436 |
+
"third_person": third_person,
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
if len(section_scores) < 2:
|
| 440 |
+
return 1.0
|
| 441 |
+
|
| 442 |
+
fp_vals = [v["first_person"] for v in section_scores.values()]
|
| 443 |
+
mean_fp = sum(fp_vals) / len(fp_vals)
|
| 444 |
+
if mean_fp == 0:
|
| 445 |
+
return 1.0
|
| 446 |
+
|
| 447 |
+
std_fp = (sum((v - mean_fp) ** 2 for v in fp_vals) / len(fp_vals)) ** 0.5
|
| 448 |
+
cv_fp = std_fp / (mean_fp + 1e-9)
|
| 449 |
+
|
| 450 |
+
# High CV = inconsistent authorship voice
|
| 451 |
+
return round(max(0.0, 1.0 - min(cv_fp, 1.0)), 4)
|
| 452 |
+
|
| 453 |
+
def _check_section_order(self, sections: dict) -> tuple:
|
| 454 |
+
"""
|
| 455 |
+
Verify that sections appear in the expected academic order.
|
| 456 |
+
Out-of-order sections may indicate copy-paste assembly.
|
| 457 |
+
"""
|
| 458 |
+
if not sections:
|
| 459 |
+
return True, []
|
| 460 |
+
|
| 461 |
+
found_order = [
|
| 462 |
+
marker for marker in EXPECTED_ORDER
|
| 463 |
+
if marker in sections
|
| 464 |
+
]
|
| 465 |
+
|
| 466 |
+
if len(found_order) < 2:
|
| 467 |
+
return True, []
|
| 468 |
+
|
| 469 |
+
flags = []
|
| 470 |
+
correct = True
|
| 471 |
+
prev_index = -1
|
| 472 |
+
|
| 473 |
+
for sec in found_order:
|
| 474 |
+
curr_index = EXPECTED_ORDER.index(sec)
|
| 475 |
+
if curr_index < prev_index:
|
| 476 |
+
correct = False
|
| 477 |
+
flags.append(
|
| 478 |
+
f"'{sec}' appears after section(s) that should follow it"
|
| 479 |
+
)
|
| 480 |
+
prev_index = curr_index
|
| 481 |
+
|
| 482 |
+
return correct, flags
|
| 483 |
+
|
| 484 |
+
def _detect_template_writing(self, text: str) -> tuple:
|
| 485 |
+
"""
|
| 486 |
+
Detect boilerplate / paper-mill template phrases.
|
| 487 |
+
High density of template phrases is associated with
|
| 488 |
+
AI-generated papers and paper mills.
|
| 489 |
+
"""
|
| 490 |
+
text_lower = text.lower()
|
| 491 |
+
found = [p for p in TEMPLATE_PHRASES if p in text_lower]
|
| 492 |
+
words = text.split()
|
| 493 |
+
n_words = max(len(words), 1)
|
| 494 |
+
|
| 495 |
+
# Normalize by text length
|
| 496 |
+
raw_score = len(found) / max(len(TEMPLATE_PHRASES), 1)
|
| 497 |
+
density_pen = min(len(found) / (n_words / 100), 0.5)
|
| 498 |
+
score = min(raw_score * 0.6 + density_pen * 0.4, 1.0)
|
| 499 |
+
|
| 500 |
+
return round(score, 4), found
|
| 501 |
+
|
| 502 |
+
def _get_risk_level(self, score: float) -> str:
|
| 503 |
+
if score >= 0.70: return "critical"
|
| 504 |
+
if score >= 0.45: return "high"
|
| 505 |
+
if score >= 0.20: return "medium"
|
| 506 |
+
return "low"
|
| 507 |
+
|
| 508 |
+
def _build_summary(
|
| 509 |
+
self, score: float, risk: str,
|
| 510 |
+
n_anomalies: int, ai_count: int, flags: list
|
| 511 |
+
) -> str:
|
| 512 |
+
parts = []
|
| 513 |
+
if n_anomalies:
|
| 514 |
+
parts.append(f"{n_anomalies} style anomaly/anomalies detected")
|
| 515 |
+
if ai_count >= 5:
|
| 516 |
+
parts.append(f"{ai_count} AI-typical phrases found")
|
| 517 |
+
if not parts:
|
| 518 |
+
parts.append("No significant style inconsistencies detected")
|
| 519 |
+
|
| 520 |
+
return (
|
| 521 |
+
f"Fraud Fingerprint analysis: {', '.join(parts)}. "
|
| 522 |
+
f"Fingerprint score: {round(score * 100)}%. "
|
| 523 |
+
f"Risk level: {risk.upper()}."
|
| 524 |
+
)
|
src/scipeerai/modules/granularity_analyzer.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import math
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import List, Tuple
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
+
|
| 9 |
+
BENFORD_EXPECTED = [
|
| 10 |
+
math.log10(1 + 1 / d) for d in range(1, 10)
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
SUSPICIOUS_DECIMAL_THRESHOLD = 4 # > 4 decimal places is suspicious
|
| 14 |
+
EXTREME_DECIMAL_THRESHOLD = 8 # > 8 decimal places is very suspicious
|
| 15 |
+
MIN_NUMBERS_FOR_BENFORD = 4 # lowered from 5 for better sensitivity
|
| 16 |
+
ROUND_NUMBER_THRESHOLD = 0.6
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ββ Data classes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class GranularityFlag:
|
| 23 |
+
flag_type: str
|
| 24 |
+
severity: str
|
| 25 |
+
description: str
|
| 26 |
+
evidence: str
|
| 27 |
+
suggestion: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class GranularityResult:
|
| 32 |
+
digit_preference_score: float
|
| 33 |
+
benford_score: float
|
| 34 |
+
round_number_ratio: float
|
| 35 |
+
granularity_score: float
|
| 36 |
+
risk_level: str
|
| 37 |
+
summary: str
|
| 38 |
+
flags: List[GranularityFlag] = field(default_factory=list)
|
| 39 |
+
flags_count: int = 0
|
| 40 |
+
suspicious_numbers: List[str] = field(default_factory=list)
|
| 41 |
+
extreme_precision: List[str] = field(default_factory=list)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Main class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
class GranularityAnalyzer:
|
| 47 |
+
"""
|
| 48 |
+
Granularity Analyzer v2.3.4
|
| 49 |
+
|
| 50 |
+
Applies Benford's Law and precision analysis to numeric data.
|
| 51 |
+
Real measurements follow predictable digit distributions β
|
| 52 |
+
fabricated data does not.
|
| 53 |
+
|
| 54 |
+
v2.3.4 upgrades:
|
| 55 |
+
- Raw string extraction preserves full decimal precision
|
| 56 |
+
- Extreme precision detection (> 8 decimal places)
|
| 57 |
+
- Lower Benford threshold (4 numbers instead of 5)
|
| 58 |
+
- Suspicious precision threshold lowered (> 4 instead of > 5)
|
| 59 |
+
- Terminal digit bias detection improved
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def analyze(self, text: str) -> GranularityResult:
|
| 63 |
+
raw_numbers, float_numbers = self._extract_numbers(text)
|
| 64 |
+
flags: List[GranularityFlag] = []
|
| 65 |
+
suspicious_numbers: List[str] = []
|
| 66 |
+
extreme_precision: List[str] = []
|
| 67 |
+
|
| 68 |
+
# ββ Check 1: Benford's Law ββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
+
benford_score, benford_flags = self._check_benford(
|
| 70 |
+
float_numbers, raw_numbers
|
| 71 |
+
)
|
| 72 |
+
flags.extend(benford_flags)
|
| 73 |
+
|
| 74 |
+
# ββ Check 2: Round number clustering βββββββββββββββββββββββββββββββββ
|
| 75 |
+
round_ratio, round_flags = self._check_round_numbers(
|
| 76 |
+
float_numbers, raw_numbers
|
| 77 |
+
)
|
| 78 |
+
flags.extend(round_flags)
|
| 79 |
+
|
| 80 |
+
# ββ Check 3: Suspicious precision ββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
prec_score, prec_flags, suspicious_numbers, extreme_precision = (
|
| 82 |
+
self._check_suspicious_precision(raw_numbers)
|
| 83 |
+
)
|
| 84 |
+
flags.extend(prec_flags)
|
| 85 |
+
|
| 86 |
+
# ββ Check 4: Terminal digit bias ββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
digit_score, digit_flags = self._check_terminal_digit_bias(
|
| 88 |
+
float_numbers, raw_numbers
|
| 89 |
+
)
|
| 90 |
+
flags.extend(digit_flags)
|
| 91 |
+
|
| 92 |
+
# ββ Aggregate score βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
granularity_score = self._compute_score(
|
| 94 |
+
benford_score, round_ratio, prec_score, digit_score
|
| 95 |
+
)
|
| 96 |
+
risk_level = self._get_risk_level(granularity_score)
|
| 97 |
+
|
| 98 |
+
summary = (
|
| 99 |
+
f"Granularity analysis of {len(raw_numbers)} numerical values. "
|
| 100 |
+
f"Anomaly score: {round(granularity_score * 100)}%. "
|
| 101 |
+
f"{len(flags)} granularity concern(s) detected. "
|
| 102 |
+
f"Risk level: {risk_level.upper()}."
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
return GranularityResult(
|
| 106 |
+
digit_preference_score = round(digit_score, 4),
|
| 107 |
+
benford_score = round(benford_score, 4),
|
| 108 |
+
round_number_ratio = round(round_ratio, 4),
|
| 109 |
+
granularity_score = round(granularity_score, 4),
|
| 110 |
+
risk_level = risk_level,
|
| 111 |
+
summary = summary,
|
| 112 |
+
flags = flags,
|
| 113 |
+
flags_count = len(flags),
|
| 114 |
+
suspicious_numbers = suspicious_numbers,
|
| 115 |
+
extreme_precision = extreme_precision,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# ββ Private helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
|
| 120 |
+
def _extract_numbers(self, text: str) -> Tuple[List[str], List[float]]:
|
| 121 |
+
"""
|
| 122 |
+
v2.3.4 β Extract numbers preserving full raw string.
|
| 123 |
+
Raw strings are used for precision checking.
|
| 124 |
+
Float values are used for Benford and round-number checks.
|
| 125 |
+
"""
|
| 126 |
+
# Raw string extraction β keeps full decimal precision
|
| 127 |
+
raw_pattern = re.compile(r'\b(\d+\.\d+|\d+)\b')
|
| 128 |
+
raw_numbers = raw_pattern.findall(text)
|
| 129 |
+
|
| 130 |
+
float_numbers = []
|
| 131 |
+
for r in raw_numbers:
|
| 132 |
+
try:
|
| 133 |
+
float_numbers.append(float(r))
|
| 134 |
+
except ValueError:
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
return raw_numbers, float_numbers
|
| 138 |
+
|
| 139 |
+
def _check_benford(
|
| 140 |
+
self, float_numbers: List[float], raw_numbers: List[str]
|
| 141 |
+
) -> Tuple[float, List[GranularityFlag]]:
|
| 142 |
+
"""Apply Benford's Law to first-digit distribution."""
|
| 143 |
+
flags = []
|
| 144 |
+
|
| 145 |
+
positives = [n for n in float_numbers if n > 0]
|
| 146 |
+
if len(positives) < MIN_NUMBERS_FOR_BENFORD:
|
| 147 |
+
return 0.0, flags
|
| 148 |
+
|
| 149 |
+
first_digits = []
|
| 150 |
+
for n in positives:
|
| 151 |
+
s = str(n).lstrip('0').replace('.', '')
|
| 152 |
+
if s and s[0].isdigit() and s[0] != '0':
|
| 153 |
+
first_digits.append(int(s[0]))
|
| 154 |
+
|
| 155 |
+
if not first_digits:
|
| 156 |
+
return 0.0, flags
|
| 157 |
+
|
| 158 |
+
observed = [
|
| 159 |
+
first_digits.count(d) / len(first_digits)
|
| 160 |
+
for d in range(1, 10)
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
deviation = sum(
|
| 164 |
+
abs(o - e) for o, e in zip(observed, BENFORD_EXPECTED)
|
| 165 |
+
) / 9
|
| 166 |
+
|
| 167 |
+
if deviation > 0.15:
|
| 168 |
+
flags.append(GranularityFlag(
|
| 169 |
+
flag_type = "benford_violation",
|
| 170 |
+
severity = "high" if deviation > 0.25 else "medium",
|
| 171 |
+
description = (
|
| 172 |
+
f"First-digit distribution deviates from Benford's Law "
|
| 173 |
+
f"(deviation: {round(deviation, 3)}). "
|
| 174 |
+
f"Natural data follows Benford's Law β fabricated numbers often do not."
|
| 175 |
+
),
|
| 176 |
+
evidence = (
|
| 177 |
+
f"Benford deviation: {round(deviation, 3)} "
|
| 178 |
+
f"(threshold: 0.15) | "
|
| 179 |
+
f"Numbers analyzed: {len(positives)}"
|
| 180 |
+
),
|
| 181 |
+
suggestion = (
|
| 182 |
+
"Verify all reported numeric values against raw data. "
|
| 183 |
+
"Large Benford deviations are a validated fraud signal."
|
| 184 |
+
),
|
| 185 |
+
))
|
| 186 |
+
|
| 187 |
+
return round(deviation, 4), flags
|
| 188 |
+
|
| 189 |
+
def _check_round_numbers(
|
| 190 |
+
self, float_numbers: List[float], raw_numbers: List[str]
|
| 191 |
+
) -> Tuple[float, List[GranularityFlag]]:
|
| 192 |
+
"""Detect suspicious clustering of round numbers."""
|
| 193 |
+
flags = []
|
| 194 |
+
|
| 195 |
+
if not float_numbers:
|
| 196 |
+
return 0.0, flags
|
| 197 |
+
|
| 198 |
+
round_count = sum(
|
| 199 |
+
1 for n in float_numbers
|
| 200 |
+
if n > 0 and n == int(n) and int(n) % 5 == 0
|
| 201 |
+
)
|
| 202 |
+
round_ratio = round_count / len(float_numbers)
|
| 203 |
+
|
| 204 |
+
if round_ratio > ROUND_NUMBER_THRESHOLD:
|
| 205 |
+
flags.append(GranularityFlag(
|
| 206 |
+
flag_type = "round_number_clustering",
|
| 207 |
+
severity = "medium",
|
| 208 |
+
description = (
|
| 209 |
+
f"Unusually high proportion of round numbers detected "
|
| 210 |
+
f"({round(round_ratio * 100)}% of values). "
|
| 211 |
+
f"Real measurements rarely cluster at multiples of 5 or 10."
|
| 212 |
+
),
|
| 213 |
+
evidence = (
|
| 214 |
+
f"Round number ratio: {round(round_ratio, 3)} "
|
| 215 |
+
f"(threshold: {ROUND_NUMBER_THRESHOLD}) | "
|
| 216 |
+
f"Round values: {round_count}/{len(float_numbers)}"
|
| 217 |
+
),
|
| 218 |
+
suggestion = (
|
| 219 |
+
"Verify measurement precision. Report actual measured values "
|
| 220 |
+
"rather than rounded summaries."
|
| 221 |
+
),
|
| 222 |
+
))
|
| 223 |
+
|
| 224 |
+
return round_ratio, flags
|
| 225 |
+
|
| 226 |
+
def _check_suspicious_precision(
|
| 227 |
+
self, raw_numbers: List[str]
|
| 228 |
+
) -> Tuple[float, List[GranularityFlag], List[str], List[str]]:
|
| 229 |
+
"""
|
| 230 |
+
v2.3.4 β Detect suspicious decimal precision.
|
| 231 |
+
Uses raw strings to preserve full decimal places.
|
| 232 |
+
Flags > 4 decimal places as suspicious.
|
| 233 |
+
Flags > 8 decimal places as extreme (likely fabricated/copy-pasted constants).
|
| 234 |
+
"""
|
| 235 |
+
flags = []
|
| 236 |
+
suspicious = []
|
| 237 |
+
extreme = []
|
| 238 |
+
|
| 239 |
+
for raw in raw_numbers:
|
| 240 |
+
if '.' not in raw:
|
| 241 |
+
continue
|
| 242 |
+
decimal_part = raw.split('.')[1]
|
| 243 |
+
n_decimals = len(decimal_part)
|
| 244 |
+
|
| 245 |
+
if n_decimals > EXTREME_DECIMAL_THRESHOLD:
|
| 246 |
+
extreme.append(raw)
|
| 247 |
+
elif n_decimals > SUSPICIOUS_DECIMAL_THRESHOLD:
|
| 248 |
+
suspicious.append(raw)
|
| 249 |
+
|
| 250 |
+
score = 0.0
|
| 251 |
+
|
| 252 |
+
if extreme:
|
| 253 |
+
score = max(score, 0.80)
|
| 254 |
+
flags.append(GranularityFlag(
|
| 255 |
+
flag_type = "extreme_precision",
|
| 256 |
+
severity = "high",
|
| 257 |
+
description = (
|
| 258 |
+
f"{len(extreme)} value(s) with extreme decimal precision "
|
| 259 |
+
f"(> {EXTREME_DECIMAL_THRESHOLD} decimal places). "
|
| 260 |
+
f"This level of precision is physically impossible for most "
|
| 261 |
+
f"real measurements and suggests copy-pasted constants or fabricated data."
|
| 262 |
+
),
|
| 263 |
+
evidence = (
|
| 264 |
+
f"Extreme precision values: "
|
| 265 |
+
f"{', '.join(extreme[:5])}"
|
| 266 |
+
f"{'...' if len(extreme) > 5 else ''}"
|
| 267 |
+
),
|
| 268 |
+
suggestion = (
|
| 269 |
+
"Real measurements should be reported to appropriate significant figures. "
|
| 270 |
+
"Values with > 8 decimal places are almost always mathematical constants "
|
| 271 |
+
"or fabricated numbers."
|
| 272 |
+
),
|
| 273 |
+
))
|
| 274 |
+
|
| 275 |
+
if suspicious and not extreme:
|
| 276 |
+
score = max(score, 0.35)
|
| 277 |
+
flags.append(GranularityFlag(
|
| 278 |
+
flag_type = "suspicious_precision",
|
| 279 |
+
severity = "medium",
|
| 280 |
+
description = (
|
| 281 |
+
f"{len(suspicious)} value(s) with unusually high decimal precision "
|
| 282 |
+
f"(> {SUSPICIOUS_DECIMAL_THRESHOLD} decimal places). "
|
| 283 |
+
f"Verify that reported precision matches measurement instrument capability."
|
| 284 |
+
),
|
| 285 |
+
evidence = (
|
| 286 |
+
f"High-precision values: "
|
| 287 |
+
f"{', '.join(suspicious[:5])}"
|
| 288 |
+
f"{'...' if len(suspicious) > 5 else ''}"
|
| 289 |
+
),
|
| 290 |
+
suggestion = (
|
| 291 |
+
"Report values to the precision of your measurement instrument. "
|
| 292 |
+
"Excess precision may indicate data manipulation."
|
| 293 |
+
),
|
| 294 |
+
))
|
| 295 |
+
|
| 296 |
+
return score, flags, suspicious, extreme
|
| 297 |
+
|
| 298 |
+
def _check_terminal_digit_bias(
|
| 299 |
+
self, float_numbers: List[float], raw_numbers: List[str]
|
| 300 |
+
) -> Tuple[float, List[GranularityFlag]]:
|
| 301 |
+
"""
|
| 302 |
+
Detect terminal digit bias.
|
| 303 |
+
Humans fabricating data tend to prefer certain digits (0, 5)
|
| 304 |
+
as the last digit of reported values.
|
| 305 |
+
"""
|
| 306 |
+
flags = []
|
| 307 |
+
|
| 308 |
+
terminal_digits = []
|
| 309 |
+
for raw in raw_numbers:
|
| 310 |
+
clean = raw.replace('.', '').rstrip('0')
|
| 311 |
+
if clean and clean[-1].isdigit():
|
| 312 |
+
terminal_digits.append(int(clean[-1]))
|
| 313 |
+
|
| 314 |
+
if len(terminal_digits) < MIN_NUMBERS_FOR_BENFORD:
|
| 315 |
+
return 0.0, flags
|
| 316 |
+
|
| 317 |
+
zero_five_count = terminal_digits.count(0) + terminal_digits.count(5)
|
| 318 |
+
zero_five_ratio = zero_five_count / len(terminal_digits)
|
| 319 |
+
expected_ratio = 0.20 # random expectation: 2/10 digits are 0 or 5
|
| 320 |
+
|
| 321 |
+
if zero_five_ratio > 0.50:
|
| 322 |
+
score = min((zero_five_ratio - expected_ratio) / 0.80, 1.0)
|
| 323 |
+
flags.append(GranularityFlag(
|
| 324 |
+
flag_type = "terminal_digit_bias",
|
| 325 |
+
severity = "medium" if zero_five_ratio < 0.70 else "high",
|
| 326 |
+
description = (
|
| 327 |
+
f"Terminal digit bias detected: "
|
| 328 |
+
f"{round(zero_five_ratio * 100)}% of values end in 0 or 5 "
|
| 329 |
+
f"(expected ~20% by chance). "
|
| 330 |
+
f"Humans fabricating data disproportionately choose round endings."
|
| 331 |
+
),
|
| 332 |
+
evidence = (
|
| 333 |
+
f"Zero/five terminal digit ratio: {round(zero_five_ratio, 3)} "
|
| 334 |
+
f"(expected: {expected_ratio}) | "
|
| 335 |
+
f"Digits analyzed: {len(terminal_digits)}"
|
| 336 |
+
),
|
| 337 |
+
suggestion = (
|
| 338 |
+
"Verify all reported values against raw measurement records. "
|
| 339 |
+
"Terminal digit bias is a validated data fabrication signal."
|
| 340 |
+
),
|
| 341 |
+
))
|
| 342 |
+
return round(score, 4), flags
|
| 343 |
+
|
| 344 |
+
return 0.0, flags
|
| 345 |
+
|
| 346 |
+
def _compute_score(
|
| 347 |
+
self,
|
| 348 |
+
benford_score: float,
|
| 349 |
+
round_ratio: float,
|
| 350 |
+
prec_score: float,
|
| 351 |
+
digit_score: float,
|
| 352 |
+
) -> float:
|
| 353 |
+
round_component = min(
|
| 354 |
+
max(round_ratio - ROUND_NUMBER_THRESHOLD, 0) / (1 - ROUND_NUMBER_THRESHOLD),
|
| 355 |
+
1.0
|
| 356 |
+
)
|
| 357 |
+
score = (
|
| 358 |
+
benford_score * 0.30 +
|
| 359 |
+
round_component * 0.20 +
|
| 360 |
+
prec_score * 0.30 +
|
| 361 |
+
digit_score * 0.20
|
| 362 |
+
)
|
| 363 |
+
return min(round(score, 4), 1.0)
|
| 364 |
+
|
| 365 |
+
def _get_risk_level(self, score: float) -> str:
|
| 366 |
+
if score >= 0.70: return "critical"
|
| 367 |
+
if score >= 0.45: return "high"
|
| 368 |
+
if score >= 0.20: return "medium"
|
| 369 |
+
return "low"
|
src/scipeerai/modules/grim_test.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/grim_test.py
|
| 2 |
+
#
|
| 3 |
+
# GRIM Test β Granularity-Related Inconsistency of Means
|
| 4 |
+
# Based on: Brown & Heathers (2017), Social Psychological
|
| 5 |
+
# and Personality Science β scientifically validated.
|
| 6 |
+
#
|
| 7 |
+
# Catches mathematically impossible means given sample size.
|
| 8 |
+
# Example: mean=2.34 with n=20 is IMPOSSIBLE.
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
import math
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class GrimFlag:
|
| 17 |
+
flag_type: str
|
| 18 |
+
severity: str
|
| 19 |
+
description: str
|
| 20 |
+
evidence: str
|
| 21 |
+
suggestion: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class GrimResult:
|
| 26 |
+
impossible_means: list
|
| 27 |
+
possible_means: list
|
| 28 |
+
grim_score: float
|
| 29 |
+
risk_level: str
|
| 30 |
+
summary: str
|
| 31 |
+
flags: list = field(default_factory=list)
|
| 32 |
+
flags_count: int = 0
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class GrimTest:
|
| 36 |
+
"""
|
| 37 |
+
GRIM Test implementation.
|
| 38 |
+
Checks whether reported means are mathematically
|
| 39 |
+
possible given the reported sample size and scale.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
# regex to pull mean/average + sample size pairs
|
| 43 |
+
MEAN_PATTERN = re.compile(
|
| 44 |
+
r'(?:mean|average|m)\s*[=:]\s*(-?\d+\.?\d*)',
|
| 45 |
+
re.IGNORECASE
|
| 46 |
+
)
|
| 47 |
+
N_PATTERN = re.compile(
|
| 48 |
+
r'n\s*[=:]\s*(\d+)',
|
| 49 |
+
re.IGNORECASE
|
| 50 |
+
)
|
| 51 |
+
FULL_PATTERN = re.compile(
|
| 52 |
+
r'(?:mean|average|m)\s*[=:]\s*(-?\d+\.\d+)'
|
| 53 |
+
r'.{0,80}'
|
| 54 |
+
r'n\s*[=:]\s*(\d+)'
|
| 55 |
+
r'|'
|
| 56 |
+
r'n\s*[=:]\s*(\d+)'
|
| 57 |
+
r'.{0,80}'
|
| 58 |
+
r'(?:mean|average|m)\s*[=:]\s*(-?\d+\.\d+)',
|
| 59 |
+
re.IGNORECASE
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
def analyze(self, text: str) -> GrimResult:
|
| 63 |
+
pairs = self._extract_pairs(text)
|
| 64 |
+
impossible = []
|
| 65 |
+
possible = []
|
| 66 |
+
flags = []
|
| 67 |
+
|
| 68 |
+
for mean_val, n_val in pairs:
|
| 69 |
+
ok = self._grim_check(mean_val, n_val)
|
| 70 |
+
if ok:
|
| 71 |
+
possible.append((mean_val, n_val))
|
| 72 |
+
else:
|
| 73 |
+
impossible.append((mean_val, n_val))
|
| 74 |
+
flags.append(GrimFlag(
|
| 75 |
+
flag_type = "grim_impossible_mean",
|
| 76 |
+
severity = "high",
|
| 77 |
+
description = (
|
| 78 |
+
f"Mean={mean_val} is mathematically "
|
| 79 |
+
f"impossible with N={n_val}. "
|
| 80 |
+
f"This value cannot arise from integer "
|
| 81 |
+
f"item scores β potential data fabrication."
|
| 82 |
+
),
|
| 83 |
+
evidence = (
|
| 84 |
+
f"Reported: M={mean_val}, N={n_val} | "
|
| 85 |
+
f"Closest valid means: "
|
| 86 |
+
f"{self._nearest_valid(mean_val, n_val)}"
|
| 87 |
+
),
|
| 88 |
+
suggestion = (
|
| 89 |
+
"Re-check raw data and recalculate. "
|
| 90 |
+
"If using Likert scales, verify item "
|
| 91 |
+
"scoring and sample size."
|
| 92 |
+
),
|
| 93 |
+
))
|
| 94 |
+
|
| 95 |
+
total = len(impossible) + len(possible)
|
| 96 |
+
score = (len(impossible) / total) if total > 0 else 0.0
|
| 97 |
+
level = self._risk(score, len(impossible))
|
| 98 |
+
summary = self._build_summary(
|
| 99 |
+
impossible, possible, score, level
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
return GrimResult(
|
| 103 |
+
impossible_means = impossible,
|
| 104 |
+
possible_means = possible,
|
| 105 |
+
grim_score = round(score, 4),
|
| 106 |
+
risk_level = level,
|
| 107 |
+
summary = summary,
|
| 108 |
+
flags = flags,
|
| 109 |
+
flags_count = len(flags),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# ββ internal helpers βββββββββββββββββββββββββββββββββββββββββ
|
| 113 |
+
|
| 114 |
+
def _grim_check(self, mean: float, n: int) -> bool:
|
| 115 |
+
"""
|
| 116 |
+
Core GRIM logic.
|
| 117 |
+
A mean is possible iff (mean * n) rounds to an integer.
|
| 118 |
+
Tolerance: 0.001 to handle floating-point noise.
|
| 119 |
+
"""
|
| 120 |
+
product = mean * n
|
| 121 |
+
remainder = abs(product - round(product))
|
| 122 |
+
return remainder < 0.001
|
| 123 |
+
|
| 124 |
+
def _extract_pairs(self, text: str):
|
| 125 |
+
pairs = []
|
| 126 |
+
for m in self.FULL_PATTERN.finditer(text):
|
| 127 |
+
if m.group(1) and m.group(2):
|
| 128 |
+
mean_val = float(m.group(1))
|
| 129 |
+
n_val = int(m.group(2))
|
| 130 |
+
else:
|
| 131 |
+
mean_val = float(m.group(4))
|
| 132 |
+
n_val = int(m.group(3))
|
| 133 |
+
if 2 <= n_val <= 10000:
|
| 134 |
+
pairs.append((mean_val, n_val))
|
| 135 |
+
return pairs
|
| 136 |
+
|
| 137 |
+
def _nearest_valid(self, mean: float, n: int) -> str:
|
| 138 |
+
decimals = len(str(mean).split(".")[-1])
|
| 139 |
+
step = round(1 / n, decimals + 2)
|
| 140 |
+
lower = math.floor(mean * n) / n
|
| 141 |
+
upper = math.ceil(mean * n) / n
|
| 142 |
+
return f"{round(lower, decimals)} or {round(upper, decimals)}"
|
| 143 |
+
|
| 144 |
+
def _risk(self, score: float, count: int) -> str:
|
| 145 |
+
if count >= 3 or score >= 0.6:
|
| 146 |
+
return "critical"
|
| 147 |
+
if count == 2 or score >= 0.4:
|
| 148 |
+
return "high"
|
| 149 |
+
if count == 1 or score >= 0.2:
|
| 150 |
+
return "medium"
|
| 151 |
+
return "low"
|
| 152 |
+
|
| 153 |
+
def _build_summary(self, impossible, possible,
|
| 154 |
+
score, level) -> str:
|
| 155 |
+
total = len(impossible) + len(possible)
|
| 156 |
+
if total == 0:
|
| 157 |
+
return (
|
| 158 |
+
"GRIM Test: No mean/N pairs detected in text. "
|
| 159 |
+
"Add explicit M= and N= values for analysis."
|
| 160 |
+
)
|
| 161 |
+
pct = round(score * 100)
|
| 162 |
+
return (
|
| 163 |
+
f"GRIM Test analyzed {total} mean/N pair(s). "
|
| 164 |
+
f"{len(impossible)} impossible mean(s) detected "
|
| 165 |
+
f"({pct}% failure rate). "
|
| 166 |
+
f"Risk level: {level.upper()}."
|
| 167 |
+
)
|
src/scipeerai/modules/institutional_conflict.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/institutional_conflict.py
|
| 2 |
+
#
|
| 3 |
+
# Module 24: Institutional Conflict Score
|
| 4 |
+
# Detects author institution patterns correlated with bias,
|
| 5 |
+
# undisclosed conflicts of interest, self-serving citation
|
| 6 |
+
# patterns, and funding-driven conclusion distortion.
|
| 7 |
+
#
|
| 8 |
+
# Score attribute: conflict_score (0.0 = clean, 1.0 = high conflict)
|
| 9 |
+
# Part of SciPeerAI Phase 6 β v2.3.0
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from collections import Counter
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ββ Data classes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class ConflictFlag:
|
| 22 |
+
flag_type: str
|
| 23 |
+
severity: str
|
| 24 |
+
description: str
|
| 25 |
+
evidence: str
|
| 26 |
+
suggestion: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class ConflictResult:
|
| 31 |
+
institution_mentions: int
|
| 32 |
+
unique_institutions: int
|
| 33 |
+
funding_sources: list
|
| 34 |
+
conflict_disclosures: int
|
| 35 |
+
undisclosed_signals: int
|
| 36 |
+
self_serving_claims: int
|
| 37 |
+
industry_funding_signals: int
|
| 38 |
+
conclusion_bias_score: float
|
| 39 |
+
conflict_score: float
|
| 40 |
+
risk_level: str
|
| 41 |
+
summary: str
|
| 42 |
+
flags: list
|
| 43 |
+
flags_count: int
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ββ Compiled patterns βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
|
| 48 |
+
_INSTITUTION_RE = re.compile(
|
| 49 |
+
r'\b(university|institute|college|department|laboratory|lab|'
|
| 50 |
+
r'school of|faculty of|division of|center for|centre for|'
|
| 51 |
+
r'hospital|clinic|foundation|corporation|inc\.|ltd\.|llc|'
|
| 52 |
+
r'pharmaceut\w+|biotech\w*|tech\w+\s+(?:corp|inc|ltd))\b',
|
| 53 |
+
re.IGNORECASE,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
_FUNDING_RE = re.compile(
|
| 57 |
+
r'\b(funded by|supported by|grant from|sponsored by|'
|
| 58 |
+
r'financial support|funding from|research grant|award from|'
|
| 59 |
+
r'contract from|supported in part|partially funded|'
|
| 60 |
+
r'this work was supported|this research was funded)\b',
|
| 61 |
+
re.IGNORECASE,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
_CONFLICT_DISCLOSURE_RE = re.compile(
|
| 65 |
+
r'\b(conflict of interest|competing interest|declare no|'
|
| 66 |
+
r'no conflict|no competing|disclose|disclosure|'
|
| 67 |
+
r'authors declare|nothing to disclose|potential conflict)\b',
|
| 68 |
+
re.IGNORECASE,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
_UNDISCLOSED_SIGNAL_RE = re.compile(
|
| 72 |
+
r'\b(consultant\w* (?:for|to)|advisor\w* (?:to|for)|'
|
| 73 |
+
r'employee\w* of|employed by|stock\w*|equity|'
|
| 74 |
+
r'patent\w*|license\w*|royalt\w*|honorar\w*|'
|
| 75 |
+
r'speaker bureau|board member|scientific advisory)\b',
|
| 76 |
+
re.IGNORECASE,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
_INDUSTRY_FUNDING_RE = re.compile(
|
| 80 |
+
r'\b(pharma\w*|biotech\w*|(?:medical|health\w*)\s+(?:corp|inc|ltd|company)|'
|
| 81 |
+
r'industry (?:partner|fund|grant|support)|commercial\w* fund|'
|
| 82 |
+
r'private (?:fund|grant|support)|corporate (?:fund|grant|sponsor))\b',
|
| 83 |
+
re.IGNORECASE,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
_SELF_SERVING_RE = re.compile(
|
| 87 |
+
r'\b(our (?:previous|prior|earlier|recent) (?:work|study|research|findings|results)|'
|
| 88 |
+
r'as we (?:showed|demonstrated|reported|found|showed previously)|'
|
| 89 |
+
r'consistent with our|confirms our|validates our|'
|
| 90 |
+
r'as reported by (?:us|our group|our lab|our team)|'
|
| 91 |
+
r'building on our|extending our|in our (?:earlier|previous|prior))\b',
|
| 92 |
+
re.IGNORECASE,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
_CONCLUSION_BIAS_RE = re.compile(
|
| 96 |
+
r'\b(clearly (?:demonstrate|show|prove|establish|confirm)|'
|
| 97 |
+
r'conclusively (?:show|prove|establish|demonstrate)|'
|
| 98 |
+
r'unequivocally|undeniably|without doubt|it is clear that|'
|
| 99 |
+
r'definitively (?:show|prove|establish)|strong evidence|'
|
| 100 |
+
r'compelling evidence|overwhelmingly|irrefutably|'
|
| 101 |
+
r'leaves no doubt|beyond question)\b',
|
| 102 |
+
re.IGNORECASE,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
_INSTITUTION_NAME_RE = re.compile(
|
| 106 |
+
r'\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\s+'
|
| 107 |
+
r'(?:University|Institute|College|Laboratory|Hospital|Foundation|Corporation))\b',
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ββ Engine ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 112 |
+
|
| 113 |
+
class InstitutionalConflictDetector:
|
| 114 |
+
"""
|
| 115 |
+
Institutional Conflict Score β Module 24.
|
| 116 |
+
|
| 117 |
+
Detects:
|
| 118 |
+
- Undisclosed conflicts of interest
|
| 119 |
+
- Industry funding with biased conclusions
|
| 120 |
+
- Excessive self-citation and self-serving claims
|
| 121 |
+
- Conclusion language inflated beyond evidence
|
| 122 |
+
- Missing or inadequate conflict disclosure statements
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
def analyze(self, text: str) -> ConflictResult:
|
| 126 |
+
text = (text or "").strip()
|
| 127 |
+
if not text:
|
| 128 |
+
return self._empty_result("No text provided for conflict analysis.")
|
| 129 |
+
|
| 130 |
+
institution_mentions = len(_INSTITUTION_RE.findall(text))
|
| 131 |
+
unique_institutions = self._count_unique_institutions(text)
|
| 132 |
+
funding_sources = self._extract_funding_sources(text)
|
| 133 |
+
conflict_disclosures = len(_CONFLICT_DISCLOSURE_RE.findall(text))
|
| 134 |
+
undisclosed_signals = len(_UNDISCLOSED_SIGNAL_RE.findall(text))
|
| 135 |
+
self_serving_claims = len(_SELF_SERVING_RE.findall(text))
|
| 136 |
+
industry_signals = len(_INDUSTRY_FUNDING_RE.findall(text))
|
| 137 |
+
conclusion_bias = self._compute_conclusion_bias(text)
|
| 138 |
+
|
| 139 |
+
conflict_score = self._compute_score(
|
| 140 |
+
conflict_disclosures,
|
| 141 |
+
undisclosed_signals,
|
| 142 |
+
self_serving_claims,
|
| 143 |
+
industry_signals,
|
| 144 |
+
conclusion_bias,
|
| 145 |
+
len(funding_sources),
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
risk_level = (
|
| 149 |
+
"critical" if conflict_score >= 0.75 else
|
| 150 |
+
"high" if conflict_score >= 0.55 else
|
| 151 |
+
"medium" if conflict_score >= 0.30 else
|
| 152 |
+
"low"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
flags = self._build_flags(
|
| 156 |
+
conflict_disclosures,
|
| 157 |
+
undisclosed_signals,
|
| 158 |
+
self_serving_claims,
|
| 159 |
+
industry_signals,
|
| 160 |
+
conclusion_bias,
|
| 161 |
+
funding_sources,
|
| 162 |
+
conflict_score,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
summary = self._build_summary(
|
| 166 |
+
institution_mentions,
|
| 167 |
+
unique_institutions,
|
| 168 |
+
undisclosed_signals,
|
| 169 |
+
self_serving_claims,
|
| 170 |
+
conflict_score,
|
| 171 |
+
risk_level,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
return ConflictResult(
|
| 175 |
+
institution_mentions = institution_mentions,
|
| 176 |
+
unique_institutions = unique_institutions,
|
| 177 |
+
funding_sources = funding_sources[:10],
|
| 178 |
+
conflict_disclosures = conflict_disclosures,
|
| 179 |
+
undisclosed_signals = undisclosed_signals,
|
| 180 |
+
self_serving_claims = self_serving_claims,
|
| 181 |
+
industry_funding_signals = industry_signals,
|
| 182 |
+
conclusion_bias_score = round(conclusion_bias, 4),
|
| 183 |
+
conflict_score = round(conflict_score, 4),
|
| 184 |
+
risk_level = risk_level,
|
| 185 |
+
summary = summary,
|
| 186 |
+
flags = flags,
|
| 187 |
+
flags_count = len(flags),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# ββ Extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 191 |
+
|
| 192 |
+
def _count_unique_institutions(self, text: str) -> int:
|
| 193 |
+
matches = _INSTITUTION_NAME_RE.findall(text)
|
| 194 |
+
return len(set(m.strip().lower() for m in matches))
|
| 195 |
+
|
| 196 |
+
def _extract_funding_sources(self, text: str) -> list:
|
| 197 |
+
funding_hits = _FUNDING_RE.findall(text)
|
| 198 |
+
lines = text.split('\n')
|
| 199 |
+
sources = []
|
| 200 |
+
for line in lines:
|
| 201 |
+
if any(kw.lower() in line.lower() for kw in ['funded', 'supported', 'grant', 'sponsored']):
|
| 202 |
+
stripped = line.strip()
|
| 203 |
+
if 10 < len(stripped) < 300:
|
| 204 |
+
sources.append(stripped[:150])
|
| 205 |
+
return sources[:8] if sources else funding_hits[:8]
|
| 206 |
+
|
| 207 |
+
# ββ Analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
|
| 209 |
+
def _compute_conclusion_bias(self, text: str) -> float:
|
| 210 |
+
words = text.split()
|
| 211 |
+
total_words = max(len(words), 1)
|
| 212 |
+
bias_count = len(_CONCLUSION_BIAS_RE.findall(text))
|
| 213 |
+
return round(min(1.0, (bias_count / total_words) * 80), 4)
|
| 214 |
+
|
| 215 |
+
# ββ Scoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 216 |
+
|
| 217 |
+
def _compute_score(
|
| 218 |
+
self,
|
| 219 |
+
disclosures: int,
|
| 220 |
+
undisclosed: int,
|
| 221 |
+
self_serving: int,
|
| 222 |
+
industry: int,
|
| 223 |
+
conclusion_bias: float,
|
| 224 |
+
funding_count: int,
|
| 225 |
+
) -> float:
|
| 226 |
+
score = 0.0
|
| 227 |
+
|
| 228 |
+
if undisclosed > 0 and disclosures == 0:
|
| 229 |
+
score += min(undisclosed * 0.18, 0.45)
|
| 230 |
+
elif undisclosed > 0 and disclosures > 0:
|
| 231 |
+
score += min(undisclosed * 0.08, 0.20)
|
| 232 |
+
|
| 233 |
+
score += min(self_serving * 0.06, 0.25)
|
| 234 |
+
score += min(industry * 0.08, 0.25)
|
| 235 |
+
score += conclusion_bias * 0.25
|
| 236 |
+
|
| 237 |
+
if funding_count > 0 and disclosures == 0:
|
| 238 |
+
score += 0.10
|
| 239 |
+
|
| 240 |
+
return round(min(1.0, score), 4)
|
| 241 |
+
|
| 242 |
+
# ββ Flags βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 243 |
+
|
| 244 |
+
def _build_flags(
|
| 245 |
+
self,
|
| 246 |
+
disclosures: int,
|
| 247 |
+
undisclosed: int,
|
| 248 |
+
self_serving: int,
|
| 249 |
+
industry: int,
|
| 250 |
+
conclusion_bias: float,
|
| 251 |
+
funding_sources: list,
|
| 252 |
+
conflict_score: float,
|
| 253 |
+
) -> list:
|
| 254 |
+
flags = []
|
| 255 |
+
|
| 256 |
+
if undisclosed > 0 and disclosures == 0:
|
| 257 |
+
flags.append(ConflictFlag(
|
| 258 |
+
flag_type = "undisclosed_conflict_of_interest",
|
| 259 |
+
severity = "critical",
|
| 260 |
+
description = (
|
| 261 |
+
f"{undisclosed} potential conflict signal(s) detected "
|
| 262 |
+
f"with no corresponding conflict disclosure statement."
|
| 263 |
+
),
|
| 264 |
+
evidence = (
|
| 265 |
+
f"Text contains references to consultancy, advisory roles, "
|
| 266 |
+
f"equity, patents, or speaker bureaus ({undisclosed} signals) "
|
| 267 |
+
f"but no 'conflict of interest' or 'competing interests' declaration."
|
| 268 |
+
),
|
| 269 |
+
suggestion = (
|
| 270 |
+
"Add an explicit conflict of interest statement. "
|
| 271 |
+
"All financial relationships, consultancy roles, and equity "
|
| 272 |
+
"positions must be declared per journal ethics requirements."
|
| 273 |
+
),
|
| 274 |
+
))
|
| 275 |
+
|
| 276 |
+
if industry > 0 and conclusion_bias >= 0.25:
|
| 277 |
+
flags.append(ConflictFlag(
|
| 278 |
+
flag_type = "industry_funded_conclusion_bias",
|
| 279 |
+
severity = "high",
|
| 280 |
+
description = (
|
| 281 |
+
"Industry funding signals combined with inflated conclusion "
|
| 282 |
+
"language suggest potential sponsor-driven result presentation."
|
| 283 |
+
),
|
| 284 |
+
evidence = (
|
| 285 |
+
f"{industry} industry funding reference(s) detected alongside "
|
| 286 |
+
f"conclusion bias score of {conclusion_bias:.2f}. "
|
| 287 |
+
f"Overly confident conclusion language in industry-funded work "
|
| 288 |
+
f"is a known integrity risk factor."
|
| 289 |
+
),
|
| 290 |
+
suggestion = (
|
| 291 |
+
"Moderate conclusion language to match the actual strength of "
|
| 292 |
+
"evidence. Avoid absolute claims ('clearly proves', 'definitively "
|
| 293 |
+
"establishes') especially in industry-funded studies."
|
| 294 |
+
),
|
| 295 |
+
))
|
| 296 |
+
|
| 297 |
+
if self_serving >= 4:
|
| 298 |
+
flags.append(ConflictFlag(
|
| 299 |
+
flag_type = "excessive_self_serving_citations",
|
| 300 |
+
severity = "medium",
|
| 301 |
+
description = (
|
| 302 |
+
f"{self_serving} self-serving reference pattern(s) detected β "
|
| 303 |
+
f"paper disproportionately builds on and validates the authors' own prior work."
|
| 304 |
+
),
|
| 305 |
+
evidence = (
|
| 306 |
+
f"Phrases like 'as we showed', 'consistent with our findings', "
|
| 307 |
+
f"'our previous work', 'building on our' appear {self_serving} times. "
|
| 308 |
+
f"This pattern can indicate citation padding or circular validation."
|
| 309 |
+
),
|
| 310 |
+
suggestion = (
|
| 311 |
+
"Ensure self-citations are genuinely necessary and not inflating "
|
| 312 |
+
"the authors' citation count. Include independent replications "
|
| 313 |
+
"that confirm the findings."
|
| 314 |
+
),
|
| 315 |
+
))
|
| 316 |
+
|
| 317 |
+
if conclusion_bias >= 0.40:
|
| 318 |
+
flags.append(ConflictFlag(
|
| 319 |
+
flag_type = "overstated_conclusions",
|
| 320 |
+
severity = "high",
|
| 321 |
+
description = (
|
| 322 |
+
"Conclusion language significantly overstates the strength "
|
| 323 |
+
"of evidence β claims exceed what the data can support."
|
| 324 |
+
),
|
| 325 |
+
evidence = (
|
| 326 |
+
f"Conclusion bias score: {conclusion_bias:.2f} (threshold: 0.40). "
|
| 327 |
+
f"Terms like 'conclusively proves', 'unequivocally demonstrates', "
|
| 328 |
+
f"'leaves no doubt' detected. These exceed standard scientific hedging."
|
| 329 |
+
),
|
| 330 |
+
suggestion = (
|
| 331 |
+
"Use appropriately hedged language: 'our results suggest', "
|
| 332 |
+
"'findings are consistent with', 'evidence supports'. "
|
| 333 |
+
"Reserve definitive language for replicated, high-powered results."
|
| 334 |
+
),
|
| 335 |
+
))
|
| 336 |
+
|
| 337 |
+
if len(funding_sources) > 0 and disclosures == 0:
|
| 338 |
+
flags.append(ConflictFlag(
|
| 339 |
+
flag_type = "funding_without_disclosure",
|
| 340 |
+
severity = "medium",
|
| 341 |
+
description = (
|
| 342 |
+
"Funding acknowledgment found but no conflict of interest "
|
| 343 |
+
"disclosure statement present."
|
| 344 |
+
),
|
| 345 |
+
evidence = (
|
| 346 |
+
f"{len(funding_sources)} funding source reference(s) detected. "
|
| 347 |
+
f"Zero conflict disclosure statements found. "
|
| 348 |
+
f"Most journals require explicit COI statements alongside funding."
|
| 349 |
+
),
|
| 350 |
+
suggestion = (
|
| 351 |
+
"Add a dedicated 'Conflicts of Interest' section. "
|
| 352 |
+
"Even 'no competing interests' must be explicitly stated per "
|
| 353 |
+
"ICMJE and most journal editorial policies."
|
| 354 |
+
),
|
| 355 |
+
))
|
| 356 |
+
|
| 357 |
+
if not flags:
|
| 358 |
+
flags.append(ConflictFlag(
|
| 359 |
+
flag_type = "no_conflict_detected",
|
| 360 |
+
severity = "low",
|
| 361 |
+
description = "No significant institutional conflict indicators detected.",
|
| 362 |
+
evidence = (
|
| 363 |
+
f"Conflict score: {conflict_score:.2f}. "
|
| 364 |
+
f"Disclosure language present or no undisclosed signals found. "
|
| 365 |
+
f"Conclusion language within acceptable bounds."
|
| 366 |
+
),
|
| 367 |
+
suggestion = (
|
| 368 |
+
"Maintain transparency by including explicit COI and "
|
| 369 |
+
"funding statements in all future submissions."
|
| 370 |
+
),
|
| 371 |
+
))
|
| 372 |
+
|
| 373 |
+
return flags
|
| 374 |
+
|
| 375 |
+
# ββ Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 376 |
+
|
| 377 |
+
def _build_summary(
|
| 378 |
+
self,
|
| 379 |
+
institution_mentions: int,
|
| 380 |
+
unique_institutions: int,
|
| 381 |
+
undisclosed: int,
|
| 382 |
+
self_serving: int,
|
| 383 |
+
conflict_score: float,
|
| 384 |
+
risk_level: str,
|
| 385 |
+
) -> str:
|
| 386 |
+
return (
|
| 387 |
+
f"Institutional conflict analysis complete. "
|
| 388 |
+
f"{institution_mentions} institution reference(s) found "
|
| 389 |
+
f"({unique_institutions} unique). "
|
| 390 |
+
f"{undisclosed} undisclosed conflict signal(s) detected. "
|
| 391 |
+
f"{self_serving} self-serving citation pattern(s) found. "
|
| 392 |
+
f"Overall conflict risk: {risk_level.upper()}."
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
# ββ Fallback ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
|
| 397 |
+
def _empty_result(self, msg: str) -> ConflictResult:
|
| 398 |
+
return ConflictResult(
|
| 399 |
+
institution_mentions = 0,
|
| 400 |
+
unique_institutions = 0,
|
| 401 |
+
funding_sources = [],
|
| 402 |
+
conflict_disclosures = 0,
|
| 403 |
+
undisclosed_signals = 0,
|
| 404 |
+
self_serving_claims = 0,
|
| 405 |
+
industry_funding_signals = 0,
|
| 406 |
+
conclusion_bias_score = 0.0,
|
| 407 |
+
conflict_score = 0.0,
|
| 408 |
+
risk_level = "low",
|
| 409 |
+
summary = msg,
|
| 410 |
+
flags = [],
|
| 411 |
+
flags_count = 0,
|
| 412 |
+
)
|
src/scipeerai/modules/llm_detector.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/llm_detector.py
|
| 2 |
+
#
|
| 3 |
+
# LLM-Generated Paper Detector
|
| 4 |
+
# Detects AI-generated academic text using:
|
| 5 |
+
# - Burstiness analysis (human text varies, LLM uniform)
|
| 6 |
+
# - Vocabulary diversity (TTR β type-token ratio)
|
| 7 |
+
# - Sentence length uniformity
|
| 8 |
+
# - Perplexity approximation via n-gram analysis
|
| 9 |
+
# - LLM signature phrases detection
|
| 10 |
+
#
|
| 11 |
+
# Completely novel approach β no free tool does this.
|
| 12 |
+
# Based on research in AI text detection (2023-2024).
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
import math
|
| 16 |
+
import statistics
|
| 17 |
+
from collections import Counter
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class LLMFlag:
|
| 23 |
+
flag_type: str
|
| 24 |
+
severity: str
|
| 25 |
+
description: str
|
| 26 |
+
evidence: str
|
| 27 |
+
suggestion: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class LLMResult:
|
| 32 |
+
burstiness_score: float
|
| 33 |
+
vocabulary_diversity: float
|
| 34 |
+
sentence_uniformity: float
|
| 35 |
+
llm_phrase_count: int
|
| 36 |
+
llm_score: float
|
| 37 |
+
risk_level: str
|
| 38 |
+
summary: str
|
| 39 |
+
flags: list = field(default_factory=list)
|
| 40 |
+
flags_count: int = 0
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LLMDetector:
|
| 44 |
+
"""
|
| 45 |
+
LLM-Generated Paper Detector.
|
| 46 |
+
Human writing is bursty β complex sentences mixed
|
| 47 |
+
with simple ones, varied vocabulary, irregular rhythm.
|
| 48 |
+
LLM writing is uniform β consistent complexity,
|
| 49 |
+
repetitive structures, characteristic phrases.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
# LLM signature phrases β common in GPT/Claude output
|
| 53 |
+
LLM_PHRASES = [
|
| 54 |
+
"it is worth noting",
|
| 55 |
+
"it is important to note",
|
| 56 |
+
"it should be noted",
|
| 57 |
+
"furthermore",
|
| 58 |
+
"moreover",
|
| 59 |
+
"in conclusion",
|
| 60 |
+
"in summary",
|
| 61 |
+
"this paper presents",
|
| 62 |
+
"this study aims to",
|
| 63 |
+
"the results demonstrate",
|
| 64 |
+
"the findings suggest",
|
| 65 |
+
"significantly",
|
| 66 |
+
"notably",
|
| 67 |
+
"interestingly",
|
| 68 |
+
"it is evident",
|
| 69 |
+
"plays a crucial role",
|
| 70 |
+
"plays an important role",
|
| 71 |
+
"has been widely studied",
|
| 72 |
+
"in recent years",
|
| 73 |
+
"state of the art",
|
| 74 |
+
"state-of-the-art",
|
| 75 |
+
"leveraging",
|
| 76 |
+
"utilize",
|
| 77 |
+
"utilizes",
|
| 78 |
+
"delve into",
|
| 79 |
+
"delves into",
|
| 80 |
+
"shed light on",
|
| 81 |
+
"sheds light on",
|
| 82 |
+
"comprehensive analysis",
|
| 83 |
+
"robust framework",
|
| 84 |
+
"novel approach",
|
| 85 |
+
"cutting-edge",
|
| 86 |
+
"landscape of",
|
| 87 |
+
"in the realm of",
|
| 88 |
+
"a testament to",
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
# Sentence splitter
|
| 92 |
+
SENT_PAT = re.compile(r'[.!?]+\s+')
|
| 93 |
+
|
| 94 |
+
# Word tokenizer
|
| 95 |
+
WORD_PAT = re.compile(r'\b[a-z]+\b', re.IGNORECASE)
|
| 96 |
+
|
| 97 |
+
def analyze(self, text: str) -> LLMResult:
|
| 98 |
+
if len(text.strip()) < 100:
|
| 99 |
+
return LLMResult(
|
| 100 |
+
burstiness_score = 0.0,
|
| 101 |
+
vocabulary_diversity = 1.0,
|
| 102 |
+
sentence_uniformity = 0.0,
|
| 103 |
+
llm_phrase_count = 0,
|
| 104 |
+
llm_score = 0.0,
|
| 105 |
+
risk_level = "low",
|
| 106 |
+
summary = (
|
| 107 |
+
"LLM Detection: Insufficient text for analysis "
|
| 108 |
+
"(minimum 100 characters required)."
|
| 109 |
+
),
|
| 110 |
+
flags = [],
|
| 111 |
+
flags_count= 0,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
sentences = self._split_sentences(text)
|
| 115 |
+
words = self._tokenize(text)
|
| 116 |
+
flags = []
|
| 117 |
+
|
| 118 |
+
# ββ 1. Burstiness Analysis ββββββββββββββββββββββββββββββββ
|
| 119 |
+
burstiness = self._burstiness(sentences)
|
| 120 |
+
|
| 121 |
+
# ββ 2. Vocabulary Diversity (TTR) βββββββββββββββββββββββββ
|
| 122 |
+
ttr = self._type_token_ratio(words)
|
| 123 |
+
|
| 124 |
+
# ββ 3. Sentence Length Uniformity βββββββββββββββββββββββββ
|
| 125 |
+
uniformity = self._sentence_uniformity(sentences)
|
| 126 |
+
|
| 127 |
+
# ββ 4. LLM Phrase Detection βββββββββββββββββββββββββββββββ
|
| 128 |
+
phrase_count, phrases_found = self._detect_phrases(text)
|
| 129 |
+
|
| 130 |
+
# ββ Flag 1: Low burstiness ββββββββββββββββββββββββββββββββ
|
| 131 |
+
if burstiness < 0.3 and len(sentences) >= 5:
|
| 132 |
+
flags.append(LLMFlag(
|
| 133 |
+
flag_type = "low_burstiness",
|
| 134 |
+
severity = "high" if burstiness < 0.15 else "medium",
|
| 135 |
+
description = (
|
| 136 |
+
f"Text burstiness score: {round(burstiness, 3)}. "
|
| 137 |
+
f"Human writing naturally varies between complex "
|
| 138 |
+
f"and simple sentences (high burstiness). "
|
| 139 |
+
f"This text shows unusually uniform complexity β "
|
| 140 |
+
f"a strong indicator of LLM generation."
|
| 141 |
+
),
|
| 142 |
+
evidence = (
|
| 143 |
+
f"Burstiness: {round(burstiness, 3)} "
|
| 144 |
+
f"(human avg: 0.4-0.8) | "
|
| 145 |
+
f"Sentences analyzed: {len(sentences)}"
|
| 146 |
+
),
|
| 147 |
+
suggestion = (
|
| 148 |
+
"If AI was used, disclose it per journal policy. "
|
| 149 |
+
"Human-written text naturally has rhythm variation. "
|
| 150 |
+
"Review for AI assistance disclosure requirements."
|
| 151 |
+
),
|
| 152 |
+
))
|
| 153 |
+
|
| 154 |
+
# ββ Flag 2: Low vocabulary diversity βββββββββββββββββββββ
|
| 155 |
+
if ttr < 0.4 and len(words) >= 50:
|
| 156 |
+
flags.append(LLMFlag(
|
| 157 |
+
flag_type = "low_vocabulary_diversity",
|
| 158 |
+
severity = "medium",
|
| 159 |
+
description = (
|
| 160 |
+
f"Type-Token Ratio: {round(ttr, 3)}. "
|
| 161 |
+
f"Low vocabulary diversity suggests repetitive "
|
| 162 |
+
f"word usage typical of LLM output. "
|
| 163 |
+
f"Human academic writing typically scores >0.5."
|
| 164 |
+
),
|
| 165 |
+
evidence = (
|
| 166 |
+
f"TTR: {round(ttr, 3)} | "
|
| 167 |
+
f"Unique words: {len(set(w.lower() for w in words))} / "
|
| 168 |
+
f"Total words: {len(words)}"
|
| 169 |
+
),
|
| 170 |
+
suggestion = (
|
| 171 |
+
"Vary vocabulary and sentence structure. "
|
| 172 |
+
"If AI-assisted, follow institutional disclosure policy."
|
| 173 |
+
),
|
| 174 |
+
))
|
| 175 |
+
|
| 176 |
+
# ββ Flag 3: High sentence uniformity βββββββββββββββββββββ
|
| 177 |
+
if uniformity > 0.7 and len(sentences) >= 5:
|
| 178 |
+
flags.append(LLMFlag(
|
| 179 |
+
flag_type = "high_sentence_uniformity",
|
| 180 |
+
severity = "medium",
|
| 181 |
+
description = (
|
| 182 |
+
f"Sentence length uniformity: {round(uniformity*100)}%. "
|
| 183 |
+
f"All sentences are suspiciously similar in length. "
|
| 184 |
+
f"LLMs tend to produce consistent sentence lengths; "
|
| 185 |
+
f"human writers vary naturally."
|
| 186 |
+
),
|
| 187 |
+
evidence = (
|
| 188 |
+
f"Uniformity score: {round(uniformity*100)}% | "
|
| 189 |
+
f"Sentences: {len(sentences)}"
|
| 190 |
+
),
|
| 191 |
+
suggestion = (
|
| 192 |
+
"Natural academic writing mixes short and long "
|
| 193 |
+
"sentences. High uniformity is an LLM signal."
|
| 194 |
+
),
|
| 195 |
+
))
|
| 196 |
+
|
| 197 |
+
# ββ Flag 4: LLM signature phrases ββββββββββββββββββββββββ
|
| 198 |
+
if phrase_count >= 3:
|
| 199 |
+
flags.append(LLMFlag(
|
| 200 |
+
flag_type = "llm_signature_phrases",
|
| 201 |
+
severity = "high" if phrase_count >= 6 else "medium",
|
| 202 |
+
description = (
|
| 203 |
+
f"{phrase_count} LLM-characteristic phrase(s) detected. "
|
| 204 |
+
f"Phrases like 'it is worth noting', 'furthermore', "
|
| 205 |
+
f"'delve into' are disproportionately common in "
|
| 206 |
+
f"AI-generated text compared to human writing."
|
| 207 |
+
),
|
| 208 |
+
evidence = (
|
| 209 |
+
f"Phrases found: {', '.join(phrases_found[:6])} | "
|
| 210 |
+
f"Count: {phrase_count}"
|
| 211 |
+
),
|
| 212 |
+
suggestion = (
|
| 213 |
+
"Replace generic transitional phrases with "
|
| 214 |
+
"discipline-specific language. Disclose AI use "
|
| 215 |
+
"if applicable per journal requirements."
|
| 216 |
+
),
|
| 217 |
+
))
|
| 218 |
+
|
| 219 |
+
score = self._aggregate_score(
|
| 220 |
+
burstiness, ttr, uniformity, phrase_count, sentences, words
|
| 221 |
+
)
|
| 222 |
+
level = self._risk(score, len(flags))
|
| 223 |
+
summary = self._build_summary(
|
| 224 |
+
score, level, burstiness, ttr, phrase_count, len(sentences)
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
return LLMResult(
|
| 228 |
+
burstiness_score = round(burstiness, 4),
|
| 229 |
+
vocabulary_diversity = round(ttr, 4),
|
| 230 |
+
sentence_uniformity = round(uniformity, 4),
|
| 231 |
+
llm_phrase_count = phrase_count,
|
| 232 |
+
llm_score = round(score, 4),
|
| 233 |
+
risk_level = level,
|
| 234 |
+
summary = summary,
|
| 235 |
+
flags = flags,
|
| 236 |
+
flags_count = len(flags),
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# ββ internal helpers βββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
+
|
| 241 |
+
def _split_sentences(self, text: str) -> list:
|
| 242 |
+
sentences = self.SENT_PAT.split(text.strip())
|
| 243 |
+
return [s.strip() for s in sentences if len(s.strip()) > 10]
|
| 244 |
+
|
| 245 |
+
def _tokenize(self, text: str) -> list:
|
| 246 |
+
return self.WORD_PAT.findall(text)
|
| 247 |
+
|
| 248 |
+
def _burstiness(self, sentences: list) -> float:
|
| 249 |
+
"""
|
| 250 |
+
Burstiness = coefficient of variation of sentence lengths.
|
| 251 |
+
High burstiness = human-like variation.
|
| 252 |
+
Low burstiness = LLM-like uniformity.
|
| 253 |
+
"""
|
| 254 |
+
if len(sentences) < 3:
|
| 255 |
+
return 0.5
|
| 256 |
+
lengths = [len(s.split()) for s in sentences]
|
| 257 |
+
if statistics.mean(lengths) == 0:
|
| 258 |
+
return 0.5
|
| 259 |
+
cv = statistics.stdev(lengths) / statistics.mean(lengths)
|
| 260 |
+
return min(cv, 1.0)
|
| 261 |
+
|
| 262 |
+
def _type_token_ratio(self, words: list) -> float:
|
| 263 |
+
"""TTR = unique words / total words. Higher = more diverse."""
|
| 264 |
+
if not words:
|
| 265 |
+
return 1.0
|
| 266 |
+
# Use sliding window TTR for longer texts
|
| 267 |
+
window = min(len(words), 100)
|
| 268 |
+
sample = words[:window]
|
| 269 |
+
unique = len(set(w.lower() for w in sample))
|
| 270 |
+
return unique / len(sample)
|
| 271 |
+
|
| 272 |
+
def _sentence_uniformity(self, sentences: list) -> float:
|
| 273 |
+
"""
|
| 274 |
+
How uniform are sentence lengths?
|
| 275 |
+
1.0 = all same length (LLM-like)
|
| 276 |
+
0.0 = highly varied (human-like)
|
| 277 |
+
"""
|
| 278 |
+
if len(sentences) < 3:
|
| 279 |
+
return 0.0
|
| 280 |
+
lengths = [len(s.split()) for s in sentences]
|
| 281 |
+
mean = statistics.mean(lengths)
|
| 282 |
+
if mean == 0:
|
| 283 |
+
return 0.0
|
| 284 |
+
stdev = statistics.stdev(lengths)
|
| 285 |
+
cv = stdev / mean
|
| 286 |
+
# Invert: high CV = low uniformity
|
| 287 |
+
return max(0.0, 1.0 - min(cv, 1.0))
|
| 288 |
+
|
| 289 |
+
def _detect_phrases(self, text: str) -> tuple:
|
| 290 |
+
text_lower = text.lower()
|
| 291 |
+
found = []
|
| 292 |
+
for phrase in self.LLM_PHRASES:
|
| 293 |
+
if phrase in text_lower:
|
| 294 |
+
found.append(phrase)
|
| 295 |
+
return len(found), found
|
| 296 |
+
|
| 297 |
+
def _aggregate_score(self, burstiness, ttr, uniformity,
|
| 298 |
+
phrase_count, sentences, words) -> float:
|
| 299 |
+
if len(sentences) < 3:
|
| 300 |
+
return 0.0
|
| 301 |
+
# Normalize components to 0-1 risk
|
| 302 |
+
burst_risk = max(0, 1 - (burstiness / 0.5))
|
| 303 |
+
ttr_risk = max(0, 1 - (ttr / 0.6))
|
| 304 |
+
uniform_risk = uniformity
|
| 305 |
+
phrase_risk = min(phrase_count / 8, 1.0)
|
| 306 |
+
|
| 307 |
+
score = (
|
| 308 |
+
burst_risk * 0.35 +
|
| 309 |
+
ttr_risk * 0.25 +
|
| 310 |
+
uniform_risk * 0.20 +
|
| 311 |
+
phrase_risk * 0.20
|
| 312 |
+
)
|
| 313 |
+
return min(round(score, 4), 1.0)
|
| 314 |
+
|
| 315 |
+
def _risk(self, score: float, flag_count: int) -> str:
|
| 316 |
+
if score >= 0.65 or flag_count >= 3:
|
| 317 |
+
return "critical"
|
| 318 |
+
if score >= 0.45 or flag_count >= 2:
|
| 319 |
+
return "high"
|
| 320 |
+
if score >= 0.25 or flag_count >= 1:
|
| 321 |
+
return "medium"
|
| 322 |
+
return "low"
|
| 323 |
+
|
| 324 |
+
def _build_summary(self, score, level, burstiness,
|
| 325 |
+
ttr, phrase_count, n_sentences) -> str:
|
| 326 |
+
pct = round(score * 100)
|
| 327 |
+
return (
|
| 328 |
+
f"LLM Detection analyzed {n_sentences} sentence(s). "
|
| 329 |
+
f"Burstiness: {round(burstiness*100)}% "
|
| 330 |
+
f"(human-like threshold: >40%). "
|
| 331 |
+
f"Vocabulary diversity: {round(ttr*100)}%. "
|
| 332 |
+
f"LLM signature phrases: {phrase_count}. "
|
| 333 |
+
f"AI-generation probability: {pct}%. "
|
| 334 |
+
f"Risk level: {level.upper()}."
|
| 335 |
+
)
|
src/scipeerai/modules/methodology_checker.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Methodology Logic Checker
|
| 2 |
+
# -------------------------
|
| 3 |
+
# The most subtle form of scientific fraud is not
|
| 4 |
+
# fabricating data β it is making claims that your
|
| 5 |
+
# method cannot actually support.
|
| 6 |
+
#
|
| 7 |
+
# "Correlation does not imply causation" is the
|
| 8 |
+
# famous example. But there are dozens of variations:
|
| 9 |
+
# underpowered claims, wrong timeframes, missing
|
| 10 |
+
# controls, self-report data for clinical conclusions.
|
| 11 |
+
#
|
| 12 |
+
# This module catches them β using both rule-based
|
| 13 |
+
# pattern matching and LLM-powered reasoning.
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
import os
|
| 17 |
+
import json
|
| 18 |
+
import urllib.request
|
| 19 |
+
import urllib.error
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from dotenv import load_dotenv
|
| 22 |
+
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ββ data structures βββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class MethodologyFlag:
|
| 30 |
+
flag_type: str
|
| 31 |
+
severity: str
|
| 32 |
+
claim: str
|
| 33 |
+
issue: str
|
| 34 |
+
evidence: str
|
| 35 |
+
suggestion: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class MethodologyResult:
|
| 40 |
+
claims_found: list
|
| 41 |
+
methods_found: list
|
| 42 |
+
flags: list
|
| 43 |
+
llm_assessment: str
|
| 44 |
+
llm_available: bool
|
| 45 |
+
risk_score: float
|
| 46 |
+
risk_level: str
|
| 47 |
+
summary: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
|
| 52 |
+
class MethodologyChecker:
|
| 53 |
+
"""
|
| 54 |
+
Two-layer methodology analysis:
|
| 55 |
+
|
| 56 |
+
Layer 1 β Rule-based: fast, deterministic, catches
|
| 57 |
+
known patterns (causation language, tiny samples,
|
| 58 |
+
missing controls, short timeframes for long-term claims)
|
| 59 |
+
|
| 60 |
+
Layer 2 β LLM reasoning: slower, probabilistic,
|
| 61 |
+
catches subtle logical gaps that rules miss.
|
| 62 |
+
Falls back gracefully if API unavailable.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
# words that claim causation β need RCT to justify
|
| 66 |
+
CAUSATION_WORDS = [
|
| 67 |
+
"causes", "caused by", "leads to", "results in",
|
| 68 |
+
"produces", "induces", "drives", "responsible for",
|
| 69 |
+
"due to", "because of", "proves that", "demonstrates that"
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
# words that only justify correlation
|
| 73 |
+
CORRELATION_WORDS = [
|
| 74 |
+
"associated with", "correlated", "linked to",
|
| 75 |
+
"related to", "predicts", "suggests"
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
# study designs that cannot prove causation
|
| 79 |
+
WEAK_DESIGNS = [
|
| 80 |
+
"survey", "questionnaire", "self-report", "cross-sectional",
|
| 81 |
+
"retrospective", "observational", "case study", "anecdotal"
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
# long-term claims need long-term studies
|
| 85 |
+
LONGTERM_CLAIMS = [
|
| 86 |
+
"long-term", "chronic", "sustained", "permanent",
|
| 87 |
+
"lasting", "durable", "years", "lifetime"
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
def __init__(self):
|
| 91 |
+
self._hf_token = os.getenv("HF_API_TOKEN", "")
|
| 92 |
+
# free model on HuggingFace β good at reasoning
|
| 93 |
+
self._hf_model = "HuggingFaceH4/zephyr-7b-beta"
|
| 94 |
+
self._hf_api_url = (
|
| 95 |
+
f"https://api-inference.huggingface.co/models/{self._hf_model}"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# ββ public method βββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
|
| 100 |
+
def analyze(self, text: str, abstract: str = "") -> MethodologyResult:
|
| 101 |
+
"""
|
| 102 |
+
Full methodology analysis.
|
| 103 |
+
Pass full paper text. Optionally pass abstract separately
|
| 104 |
+
for cleaner claim extraction.
|
| 105 |
+
"""
|
| 106 |
+
working_text = abstract if abstract else text
|
| 107 |
+
|
| 108 |
+
claims = self._extract_claims(working_text)
|
| 109 |
+
methods = self._extract_methods(text)
|
| 110 |
+
|
| 111 |
+
flags = []
|
| 112 |
+
flags.extend(self._check_causation_without_rct(text, claims))
|
| 113 |
+
flags.extend(self._check_weak_design_strong_claim(text, claims))
|
| 114 |
+
flags.extend(self._check_longterm_claim_shortterm_study(text, claims))
|
| 115 |
+
flags.extend(self._check_missing_control_group(text))
|
| 116 |
+
flags.extend(self._check_generalization(text, claims))
|
| 117 |
+
|
| 118 |
+
# try LLM reasoning β graceful fallback if unavailable
|
| 119 |
+
llm_text, llm_ok = self._llm_assess(
|
| 120 |
+
claims=claims,
|
| 121 |
+
methods=methods,
|
| 122 |
+
paper_snippet=text[:1500]
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
risk_score = self._calculate_risk(flags)
|
| 126 |
+
risk_level = self._get_risk_level(risk_score)
|
| 127 |
+
|
| 128 |
+
return MethodologyResult(
|
| 129 |
+
claims_found=claims,
|
| 130 |
+
methods_found=methods,
|
| 131 |
+
flags=flags,
|
| 132 |
+
llm_assessment=llm_text,
|
| 133 |
+
llm_available=llm_ok,
|
| 134 |
+
risk_score=round(risk_score, 3),
|
| 135 |
+
risk_level=risk_level,
|
| 136 |
+
summary=self._write_summary(flags, risk_level, llm_ok),
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
# ββ claim / method extraction βββββββββββββββββββββββββββββ
|
| 140 |
+
|
| 141 |
+
def _extract_claims(self, text: str) -> list:
|
| 142 |
+
"""
|
| 143 |
+
Pull claim-like sentences β those that assert findings,
|
| 144 |
+
conclusions, or implications.
|
| 145 |
+
Looks for language that signals a conclusion.
|
| 146 |
+
"""
|
| 147 |
+
claim_markers = [
|
| 148 |
+
"we found", "we show", "we demonstrate", "we conclude",
|
| 149 |
+
"our results", "our findings", "this study shows",
|
| 150 |
+
"this study demonstrates", "results indicate",
|
| 151 |
+
"results suggest", "data show", "analysis reveals",
|
| 152 |
+
"we report", "evidence suggests", "we establish"
|
| 153 |
+
]
|
| 154 |
+
claims = []
|
| 155 |
+
sentences = re.split(r'[.!?]', text)
|
| 156 |
+
|
| 157 |
+
for sentence in sentences:
|
| 158 |
+
sentence = sentence.strip()
|
| 159 |
+
if not sentence or len(sentence) < 20:
|
| 160 |
+
continue
|
| 161 |
+
s_lower = sentence.lower()
|
| 162 |
+
if any(marker in s_lower for marker in claim_markers):
|
| 163 |
+
claims.append(sentence)
|
| 164 |
+
|
| 165 |
+
return claims[:8] # cap at 8 β enough signal
|
| 166 |
+
|
| 167 |
+
def _extract_methods(self, text: str) -> list:
|
| 168 |
+
"""
|
| 169 |
+
Pull sentences from the methods section that describe
|
| 170 |
+
how the study was actually conducted.
|
| 171 |
+
"""
|
| 172 |
+
method_markers = [
|
| 173 |
+
"we used", "we conducted", "we recruited", "we collected",
|
| 174 |
+
"we measured", "we analyzed", "participants were",
|
| 175 |
+
"subjects were", "samples were", "data were collected",
|
| 176 |
+
"randomized", "controlled", "double-blind", "survey",
|
| 177 |
+
"questionnaire", "interview", "experiment"
|
| 178 |
+
]
|
| 179 |
+
methods = []
|
| 180 |
+
sentences = re.split(r'[.!?]', text)
|
| 181 |
+
|
| 182 |
+
for sentence in sentences:
|
| 183 |
+
sentence = sentence.strip()
|
| 184 |
+
if not sentence or len(sentence) < 20:
|
| 185 |
+
continue
|
| 186 |
+
s_lower = sentence.lower()
|
| 187 |
+
if any(marker in s_lower for marker in method_markers):
|
| 188 |
+
methods.append(sentence)
|
| 189 |
+
|
| 190 |
+
return methods[:8]
|
| 191 |
+
|
| 192 |
+
# ββ rule-based checks βββββββββββββββββββββββββββββββββββββ
|
| 193 |
+
|
| 194 |
+
def _check_causation_without_rct(self, text: str, claims: list) -> list:
|
| 195 |
+
"""
|
| 196 |
+
v2.3.2 β Detects causal language without RCT.
|
| 197 |
+
Now triggers on causal claim alone (not requiring weak_design word)
|
| 198 |
+
since strong causal assertions without RCT evidence are the real risk.
|
| 199 |
+
"""
|
| 200 |
+
flags = []
|
| 201 |
+
text_lower = text.lower()
|
| 202 |
+
|
| 203 |
+
has_causal_claim = any(
|
| 204 |
+
word in text_lower for word in self.CAUSATION_WORDS
|
| 205 |
+
)
|
| 206 |
+
has_weak_design = any(
|
| 207 |
+
design in text_lower for design in self.WEAK_DESIGNS
|
| 208 |
+
)
|
| 209 |
+
has_rct = any(
|
| 210 |
+
word in text_lower
|
| 211 |
+
for word in ["randomized", "randomised", "rct",
|
| 212 |
+
"control group", "placebo", "double-blind"]
|
| 213 |
+
)
|
| 214 |
+
# Negation check β "no control group" should NOT count as has_rct
|
| 215 |
+
has_negated_control = bool(re.search(
|
| 216 |
+
r'\b(?:no|without|did not have|lacked|absent)\s+(?:a\s+)?control\s+group\b',
|
| 217 |
+
text_lower
|
| 218 |
+
))
|
| 219 |
+
if has_negated_control:
|
| 220 |
+
has_rct = False
|
| 221 |
+
|
| 222 |
+
# Trigger on strong causal claim alone if no RCT present
|
| 223 |
+
# OR causal claim + weak design combo (original logic)
|
| 224 |
+
should_flag = (has_causal_claim and not has_rct) or \
|
| 225 |
+
(has_causal_claim and has_weak_design and not has_rct)
|
| 226 |
+
|
| 227 |
+
# Also catch absolute/overconfident causal language as a standalone signal
|
| 228 |
+
absolute_causal = any(
|
| 229 |
+
phrase in text_lower
|
| 230 |
+
for phrase in ["prove conclusively", "proves conclusively",
|
| 231 |
+
"without doubt", "clearly show causation",
|
| 232 |
+
"demonstrates without doubt", "no doubt that"]
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if should_flag or (absolute_causal and not has_rct):
|
| 236 |
+
evidence_sentence = ""
|
| 237 |
+
for sentence in re.split(r'[.!?]', text):
|
| 238 |
+
if any(w in sentence.lower() for w in self.CAUSATION_WORDS) or \
|
| 239 |
+
any(p in sentence.lower() for p in ["prove conclusively", "without doubt"]):
|
| 240 |
+
evidence_sentence = sentence.strip()
|
| 241 |
+
break
|
| 242 |
+
|
| 243 |
+
design_note = (
|
| 244 |
+
f"({', '.join([d for d in self.WEAK_DESIGNS if d in text_lower])}) "
|
| 245 |
+
if has_weak_design else "(no controlled design described) "
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
flags.append(MethodologyFlag(
|
| 249 |
+
flag_type="causation_without_rct",
|
| 250 |
+
severity="high",
|
| 251 |
+
claim="Causal language detected without RCT evidence",
|
| 252 |
+
issue=(
|
| 253 |
+
"The study uses causal or absolute language "
|
| 254 |
+
f"but no randomized controlled trial design "
|
| 255 |
+
f"{design_note}is described to support this claim."
|
| 256 |
+
),
|
| 257 |
+
evidence=evidence_sentence or "See causal language in conclusions",
|
| 258 |
+
suggestion=(
|
| 259 |
+
"Causal claims require randomized controlled trials. "
|
| 260 |
+
"Replace causal language with correlation language, "
|
| 261 |
+
"or acknowledge the design limitation explicitly."
|
| 262 |
+
),
|
| 263 |
+
))
|
| 264 |
+
|
| 265 |
+
return flags
|
| 266 |
+
|
| 267 |
+
def _check_weak_design_strong_claim(
|
| 268 |
+
self, text: str, claims: list
|
| 269 |
+
) -> list:
|
| 270 |
+
"""
|
| 271 |
+
Self-report surveys and questionnaires cannot support
|
| 272 |
+
strong clinical or behavioral conclusions.
|
| 273 |
+
"""
|
| 274 |
+
flags = []
|
| 275 |
+
text_lower = text.lower()
|
| 276 |
+
|
| 277 |
+
has_self_report = any(
|
| 278 |
+
w in text_lower
|
| 279 |
+
for w in ["self-report", "self report", "questionnaire",
|
| 280 |
+
"survey", "interview", "participants reported"]
|
| 281 |
+
)
|
| 282 |
+
has_strong_claim = any(
|
| 283 |
+
w in text_lower
|
| 284 |
+
for w in ["proves", "demonstrates", "establishes",
|
| 285 |
+
"confirms", "validates", "clinical evidence"]
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
if has_self_report and has_strong_claim:
|
| 289 |
+
flags.append(MethodologyFlag(
|
| 290 |
+
flag_type="weak_design_strong_claim",
|
| 291 |
+
severity="medium",
|
| 292 |
+
claim="Strong claim based on self-report data",
|
| 293 |
+
issue=(
|
| 294 |
+
"Self-report or questionnaire data has known limitations "
|
| 295 |
+
"(social desirability bias, recall bias) that undermine "
|
| 296 |
+
"strong conclusive claims."
|
| 297 |
+
),
|
| 298 |
+
evidence="Self-report instrument combined with conclusive language",
|
| 299 |
+
suggestion=(
|
| 300 |
+
"Acknowledge self-report limitations explicitly. "
|
| 301 |
+
"Soften conclusions to match data quality."
|
| 302 |
+
),
|
| 303 |
+
))
|
| 304 |
+
|
| 305 |
+
return flags
|
| 306 |
+
|
| 307 |
+
def _check_longterm_claim_shortterm_study(
|
| 308 |
+
self, text: str, claims: list
|
| 309 |
+
) -> list:
|
| 310 |
+
"""
|
| 311 |
+
Studies lasting days or weeks cannot make
|
| 312 |
+
long-term or chronic effect claims.
|
| 313 |
+
"""
|
| 314 |
+
flags = []
|
| 315 |
+
text_lower = text.lower()
|
| 316 |
+
|
| 317 |
+
has_longterm_claim = any(
|
| 318 |
+
w in text_lower for w in self.LONGTERM_CLAIMS
|
| 319 |
+
)
|
| 320 |
+
has_shortterm_study = any(
|
| 321 |
+
w in text_lower
|
| 322 |
+
for w in ["two weeks", "2 weeks", "one week", "1 week",
|
| 323 |
+
"3 days", "7 days", "short-term pilot",
|
| 324 |
+
"preliminary study"]
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
if has_longterm_claim and has_shortterm_study:
|
| 328 |
+
flags.append(MethodologyFlag(
|
| 329 |
+
flag_type="timeframe_mismatch",
|
| 330 |
+
severity="medium",
|
| 331 |
+
claim="Long-term claim from short-term study",
|
| 332 |
+
issue=(
|
| 333 |
+
"The study duration appears insufficient to support "
|
| 334 |
+
"long-term or chronic effect claims."
|
| 335 |
+
),
|
| 336 |
+
evidence="Long-term language with short study duration",
|
| 337 |
+
suggestion=(
|
| 338 |
+
"Either extend the study duration or explicitly "
|
| 339 |
+
"limit claims to short-term effects only."
|
| 340 |
+
),
|
| 341 |
+
))
|
| 342 |
+
|
| 343 |
+
return flags
|
| 344 |
+
|
| 345 |
+
def _check_missing_control_group(self, text: str) -> list:
|
| 346 |
+
"""
|
| 347 |
+
v2.3.2 β Fixed negation handling.
|
| 348 |
+
"No control group was needed" should COUNT as missing control,
|
| 349 |
+
not be treated as evidence a control group exists.
|
| 350 |
+
"""
|
| 351 |
+
flags = []
|
| 352 |
+
text_lower = text.lower()
|
| 353 |
+
|
| 354 |
+
has_treatment = any(
|
| 355 |
+
w in text_lower
|
| 356 |
+
for w in ["treatment", "intervention", "drug", "therapy",
|
| 357 |
+
"program", "training"]
|
| 358 |
+
)
|
| 359 |
+
has_effect_claim = any(
|
| 360 |
+
w in text_lower
|
| 361 |
+
for w in ["improved", "reduced", "increased", "effective",
|
| 362 |
+
"significant effect", "works", "worked"]
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
# Check for control group mention
|
| 366 |
+
raw_has_control = any(
|
| 367 |
+
w in text_lower
|
| 368 |
+
for w in ["control group", "control condition", "placebo",
|
| 369 |
+
"comparison group", "waitlist"]
|
| 370 |
+
)
|
| 371 |
+
# Negation check β explicit denial of control group
|
| 372 |
+
has_negated_control = bool(re.search(
|
| 373 |
+
r'\b(?:no|without|did not have|lacked|absent|not needed|wasn.?t needed)\s+'
|
| 374 |
+
r'(?:a\s+)?control\s+group\b',
|
| 375 |
+
text_lower
|
| 376 |
+
)) or bool(re.search(
|
| 377 |
+
r'\bcontrol\s+group\s+(?:was\s+)?not\s+(?:needed|used|included)\b',
|
| 378 |
+
text_lower
|
| 379 |
+
))
|
| 380 |
+
|
| 381 |
+
has_control = raw_has_control and not has_negated_control
|
| 382 |
+
|
| 383 |
+
if has_treatment and has_effect_claim and not has_control:
|
| 384 |
+
severity = "high" if has_negated_control else "high"
|
| 385 |
+
evidence = (
|
| 386 |
+
"Explicit denial of control group detected"
|
| 387 |
+
if has_negated_control else
|
| 388 |
+
"Treatment + effect language with no control group mention"
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
flags.append(MethodologyFlag(
|
| 392 |
+
flag_type="missing_control_group",
|
| 393 |
+
severity=severity,
|
| 394 |
+
claim="Treatment effect claimed without control group",
|
| 395 |
+
issue=(
|
| 396 |
+
"Effect claims for a treatment or intervention "
|
| 397 |
+
"require a control group to rule out confounds, "
|
| 398 |
+
"placebo effects, and natural recovery. "
|
| 399 |
+
+ ("The paper explicitly states no control group was used."
|
| 400 |
+
if has_negated_control else "")
|
| 401 |
+
),
|
| 402 |
+
evidence=evidence,
|
| 403 |
+
suggestion=(
|
| 404 |
+
"Add a control/comparison condition, or acknowledge "
|
| 405 |
+
"that without a control group, the effect cannot be "
|
| 406 |
+
"attributed to the intervention specifically."
|
| 407 |
+
),
|
| 408 |
+
))
|
| 409 |
+
|
| 410 |
+
return flags
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _check_generalization(self, text: str, claims: list) -> list:
|
| 414 |
+
"""
|
| 415 |
+
Small, homogeneous samples cannot support
|
| 416 |
+
broad population-level generalizations.
|
| 417 |
+
"""
|
| 418 |
+
flags = []
|
| 419 |
+
text_lower = text.lower()
|
| 420 |
+
|
| 421 |
+
has_broad_claim = any(
|
| 422 |
+
w in text_lower
|
| 423 |
+
for w in ["all patients", "general population", "universally",
|
| 424 |
+
"across all", "globally applicable", "all humans"]
|
| 425 |
+
)
|
| 426 |
+
has_limited_sample = any(
|
| 427 |
+
w in text_lower
|
| 428 |
+
for w in ["undergraduate students", "college students",
|
| 429 |
+
"single institution", "convenience sample",
|
| 430 |
+
"homogeneous sample"]
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
if has_broad_claim and has_limited_sample:
|
| 434 |
+
flags.append(MethodologyFlag(
|
| 435 |
+
flag_type="overgeneralization",
|
| 436 |
+
severity="medium",
|
| 437 |
+
claim="Broad generalization from limited sample",
|
| 438 |
+
issue=(
|
| 439 |
+
"The sample characteristics (e.g., undergraduate students, "
|
| 440 |
+
"single institution) limit generalizability beyond "
|
| 441 |
+
"the studied population."
|
| 442 |
+
),
|
| 443 |
+
evidence="Broad claim language with limited sample description",
|
| 444 |
+
suggestion=(
|
| 445 |
+
"Explicitly acknowledge sampling limitations "
|
| 446 |
+
"and restrict claims to the studied population."
|
| 447 |
+
),
|
| 448 |
+
))
|
| 449 |
+
|
| 450 |
+
return flags
|
| 451 |
+
|
| 452 |
+
# ββ llm reasoning βββββββββββββββββββββββββββββββββββββββββ
|
| 453 |
+
|
| 454 |
+
def _llm_assess(
|
| 455 |
+
self, claims: list, methods: list, paper_snippet: str
|
| 456 |
+
) -> tuple:
|
| 457 |
+
"""
|
| 458 |
+
Ask an LLM to reason about whether the methods
|
| 459 |
+
logically support the claims.
|
| 460 |
+
|
| 461 |
+
Returns (assessment_text, success_bool).
|
| 462 |
+
Falls back gracefully if token missing or API down.
|
| 463 |
+
"""
|
| 464 |
+
if not self._hf_token or self._hf_token == "hf_xxxxxxxxxxxxxxxx":
|
| 465 |
+
return (
|
| 466 |
+
"LLM assessment unavailable β HF_API_TOKEN not configured.",
|
| 467 |
+
False
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
claims_text = " | ".join(claims[:3]) if claims else "Not extracted"
|
| 471 |
+
methods_text = " | ".join(methods[:3]) if methods else "Not extracted"
|
| 472 |
+
|
| 473 |
+
prompt = f"""[INST] You are a scientific peer reviewer.
|
| 474 |
+
|
| 475 |
+
Paper excerpt:
|
| 476 |
+
{paper_snippet[:800]}
|
| 477 |
+
|
| 478 |
+
Claims made: {claims_text}
|
| 479 |
+
Methods used: {methods_text}
|
| 480 |
+
|
| 481 |
+
In 2-3 sentences, identify the most critical logical gap between the methods and claims. Be specific and direct. [/INST]"""
|
| 482 |
+
|
| 483 |
+
try:
|
| 484 |
+
payload = json.dumps({
|
| 485 |
+
"inputs": prompt,
|
| 486 |
+
"parameters": {
|
| 487 |
+
"max_new_tokens": 150,
|
| 488 |
+
"temperature": 0.3,
|
| 489 |
+
"return_full_text": False,
|
| 490 |
+
}
|
| 491 |
+
}).encode("utf-8")
|
| 492 |
+
|
| 493 |
+
req = urllib.request.Request(
|
| 494 |
+
self._hf_api_url,
|
| 495 |
+
data=payload,
|
| 496 |
+
headers={
|
| 497 |
+
"Authorization": f"Bearer {self._hf_token}",
|
| 498 |
+
"Content-Type": "application/json",
|
| 499 |
+
},
|
| 500 |
+
method="POST"
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
with urllib.request.urlopen(req, timeout=30) as response:
|
| 504 |
+
result = json.loads(response.read().decode("utf-8"))
|
| 505 |
+
|
| 506 |
+
if isinstance(result, list) and result:
|
| 507 |
+
text = result[0].get("generated_text", "").strip()
|
| 508 |
+
return (text, True) if text else ("No assessment generated.", False)
|
| 509 |
+
|
| 510 |
+
return ("Unexpected API response format.", False)
|
| 511 |
+
|
| 512 |
+
except urllib.error.HTTPError as e:
|
| 513 |
+
if e.code == 503:
|
| 514 |
+
return ("LLM model loading β try again in 20 seconds.", False)
|
| 515 |
+
return (f"API error {e.code}: {str(e)}", False)
|
| 516 |
+
except Exception as e:
|
| 517 |
+
return (f"LLM unavailable: {str(e)}", False)
|
| 518 |
+
|
| 519 |
+
# ββ scoring βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 520 |
+
|
| 521 |
+
def _calculate_risk(self, flags: list) -> float:
|
| 522 |
+
weights = {"high": 0.35, "medium": 0.20, "low": 0.08}
|
| 523 |
+
score = sum(weights.get(f.severity, 0) for f in flags)
|
| 524 |
+
return min(score, 1.0)
|
| 525 |
+
|
| 526 |
+
def _get_risk_level(self, score: float) -> str:
|
| 527 |
+
if score >= 0.7: return "critical"
|
| 528 |
+
elif score >= 0.4: return "high"
|
| 529 |
+
elif score >= 0.2: return "medium"
|
| 530 |
+
return "low"
|
| 531 |
+
|
| 532 |
+
def _write_summary(
|
| 533 |
+
self, flags: list, risk_level: str, llm_ok: bool
|
| 534 |
+
) -> str:
|
| 535 |
+
if not flags:
|
| 536 |
+
base = "No methodology logic issues detected."
|
| 537 |
+
else:
|
| 538 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 539 |
+
med = sum(1 for f in flags if f.severity == "medium")
|
| 540 |
+
parts = []
|
| 541 |
+
if high: parts.append(f"{high} high-severity issue{'s' if high > 1 else ''}")
|
| 542 |
+
if med: parts.append(f"{med} medium-severity concern{'s' if med > 1 else ''}")
|
| 543 |
+
base = (
|
| 544 |
+
f"Methodology checker flagged {', '.join(parts)}. "
|
| 545 |
+
f"Risk level: {risk_level.upper()}."
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
llm_note = " LLM reasoning included." if llm_ok else " LLM reasoning unavailable."
|
| 549 |
+
return base + llm_note
|
src/scipeerai/modules/novelty_scorer.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/novelty_scorer.py
|
| 2 |
+
#
|
| 3 |
+
# Novelty Scorer
|
| 4 |
+
# --------------
|
| 5 |
+
# Science advances through genuinely new contributions.
|
| 6 |
+
# But with 4 million papers published per year, it is
|
| 7 |
+
# increasingly difficult to know if a contribution is
|
| 8 |
+
# truly novel or an incremental rehash of existing work.
|
| 9 |
+
#
|
| 10 |
+
# This module estimates novelty by:
|
| 11 |
+
# 1. Extracting key claims from the paper
|
| 12 |
+
# 2. Searching existing literature via Semantic Scholar
|
| 13 |
+
# 3. Analyzing title/abstract overlap patterns
|
| 14 |
+
# 4. Scoring novelty from 0.0 (not novel) to 1.0 (highly novel)
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
import json
|
| 18 |
+
import time
|
| 19 |
+
import urllib.request
|
| 20 |
+
import urllib.parse
|
| 21 |
+
import urllib.error
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ββ data structures βββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class NoveltyFlag:
|
| 29 |
+
flag_type: str
|
| 30 |
+
severity: str
|
| 31 |
+
description: str
|
| 32 |
+
evidence: str
|
| 33 |
+
suggestion: str
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class RelatedWork:
|
| 38 |
+
title: str
|
| 39 |
+
year: int
|
| 40 |
+
authors: list
|
| 41 |
+
similarity_signal: str
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class NoveltyResult:
|
| 46 |
+
novelty_score: float # 0.0 = not novel, 1.0 = highly novel
|
| 47 |
+
novelty_level: str # "low" / "moderate" / "high" / "very_high"
|
| 48 |
+
related_works_found: list
|
| 49 |
+
key_terms_extracted: list
|
| 50 |
+
flags: list
|
| 51 |
+
literature_accessible: bool # was Semantic Scholar reachable?
|
| 52 |
+
risk_level: str
|
| 53 |
+
summary: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
|
| 58 |
+
class NoveltyScorer:
|
| 59 |
+
"""
|
| 60 |
+
Estimates paper novelty using two approaches:
|
| 61 |
+
|
| 62 |
+
Approach 1 β Structural novelty signals (offline):
|
| 63 |
+
Looks for language patterns that indicate genuine
|
| 64 |
+
novelty vs incremental work. Fast, always available.
|
| 65 |
+
|
| 66 |
+
Approach 2 β Literature search (online):
|
| 67 |
+
Uses Semantic Scholar free API to find related papers.
|
| 68 |
+
Graceful fallback if API unavailable.
|
| 69 |
+
|
| 70 |
+
Final score combines both approaches.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
# phrases that signal genuine novelty
|
| 74 |
+
NOVELTY_SIGNALS = [
|
| 75 |
+
"first study to", "first paper to", "novel approach",
|
| 76 |
+
"we propose a new", "we introduce", "we present a new",
|
| 77 |
+
"previously unexplored", "to our knowledge",
|
| 78 |
+
"no prior work", "first time", "new framework",
|
| 79 |
+
"new method", "we develop a novel", "first investigation",
|
| 80 |
+
"pioneer", "groundbreaking", "unprecedented",
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
# phrases that signal incremental / derivative work
|
| 84 |
+
INCREMENTAL_SIGNALS = [
|
| 85 |
+
"extending previous work", "building on",
|
| 86 |
+
"similar to previous", "following the approach of",
|
| 87 |
+
"replicating", "consistent with prior",
|
| 88 |
+
"confirming previous findings", "in line with",
|
| 89 |
+
"as shown previously", "extending the work of",
|
| 90 |
+
"we replicate", "corroborating",
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
# domain keywords for better search queries
|
| 94 |
+
DOMAIN_KEYWORDS = [
|
| 95 |
+
"machine learning", "deep learning", "neural network",
|
| 96 |
+
"transformer", "language model", "computer vision",
|
| 97 |
+
"natural language processing", "reinforcement learning",
|
| 98 |
+
"clinical trial", "randomized controlled",
|
| 99 |
+
"meta-analysis", "systematic review",
|
| 100 |
+
"molecular", "genomics", "proteomics",
|
| 101 |
+
"quantum", "photonic", "nanomaterial",
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
def __init__(self):
|
| 105 |
+
self._api_base = (
|
| 106 |
+
"https://api.semanticscholar.org/graph/v1/paper/search"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# ββ public method βββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
|
| 111 |
+
def analyze(self, text: str, title: str = "") -> NoveltyResult:
|
| 112 |
+
"""
|
| 113 |
+
Full novelty analysis.
|
| 114 |
+
Combines structural signals with optional literature search.
|
| 115 |
+
"""
|
| 116 |
+
key_terms = self._extract_key_terms(text, title)
|
| 117 |
+
struct_score = self._structural_novelty_score(text)
|
| 118 |
+
|
| 119 |
+
# try live literature search
|
| 120 |
+
related_works = []
|
| 121 |
+
api_ok = False
|
| 122 |
+
|
| 123 |
+
if key_terms:
|
| 124 |
+
related_works, api_ok = self._search_literature(
|
| 125 |
+
key_terms, title
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# combine scores
|
| 129 |
+
if api_ok and related_works:
|
| 130 |
+
# literature found β factor in search results
|
| 131 |
+
overlap_penalty = min(len(related_works) * 0.08, 0.4)
|
| 132 |
+
final_score = max(struct_score - overlap_penalty, 0.1)
|
| 133 |
+
else:
|
| 134 |
+
# no API or no results β use structural only
|
| 135 |
+
final_score = struct_score
|
| 136 |
+
|
| 137 |
+
final_score = round(final_score, 3)
|
| 138 |
+
novelty_level = self._get_novelty_level(final_score)
|
| 139 |
+
risk_level = self._get_risk_level(final_score)
|
| 140 |
+
|
| 141 |
+
flags = []
|
| 142 |
+
flags.extend(self._check_novelty_flags(text, final_score))
|
| 143 |
+
flags.extend(self._check_incremental_language(text))
|
| 144 |
+
|
| 145 |
+
return NoveltyResult(
|
| 146 |
+
novelty_score=final_score,
|
| 147 |
+
novelty_level=novelty_level,
|
| 148 |
+
related_works_found=related_works,
|
| 149 |
+
key_terms_extracted=key_terms,
|
| 150 |
+
flags=flags,
|
| 151 |
+
literature_accessible=api_ok,
|
| 152 |
+
risk_level=risk_level,
|
| 153 |
+
summary=self._write_summary(
|
| 154 |
+
final_score, novelty_level,
|
| 155 |
+
related_works, api_ok, flags
|
| 156 |
+
),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# ββ term extraction βββββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
|
| 161 |
+
def _extract_key_terms(self, text: str, title: str) -> list:
|
| 162 |
+
"""
|
| 163 |
+
Extract the most meaningful terms for literature search.
|
| 164 |
+
Priority: title terms > domain keywords > noun phrases.
|
| 165 |
+
"""
|
| 166 |
+
terms = []
|
| 167 |
+
|
| 168 |
+
# title words are most informative
|
| 169 |
+
if title:
|
| 170 |
+
words = re.findall(r'\b[A-Z][a-z]{3,}\b', title)
|
| 171 |
+
terms.extend(words[:5])
|
| 172 |
+
|
| 173 |
+
# domain keywords present in text
|
| 174 |
+
t_lower = text.lower()
|
| 175 |
+
for kw in self.DOMAIN_KEYWORDS:
|
| 176 |
+
if kw in t_lower:
|
| 177 |
+
terms.append(kw)
|
| 178 |
+
if len(terms) >= 6:
|
| 179 |
+
break
|
| 180 |
+
|
| 181 |
+
# fallback: capitalized noun phrases from text
|
| 182 |
+
if len(terms) < 3:
|
| 183 |
+
caps = re.findall(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', text)
|
| 184 |
+
terms.extend(caps[:3])
|
| 185 |
+
|
| 186 |
+
# deduplicate
|
| 187 |
+
seen = set()
|
| 188 |
+
unique = []
|
| 189 |
+
for t in terms:
|
| 190 |
+
if t.lower() not in seen:
|
| 191 |
+
seen.add(t.lower())
|
| 192 |
+
unique.append(t)
|
| 193 |
+
|
| 194 |
+
return unique[:6]
|
| 195 |
+
|
| 196 |
+
# ββ structural scoring ββββββββββββββββββββββββββββββββββββ
|
| 197 |
+
|
| 198 |
+
def _structural_novelty_score(self, text: str) -> float:
|
| 199 |
+
"""
|
| 200 |
+
Score novelty from language patterns alone.
|
| 201 |
+
Novelty signals push score up, incremental signals push down.
|
| 202 |
+
Base score: 0.5 (neutral β unknown)
|
| 203 |
+
"""
|
| 204 |
+
t_lower = text.lower()
|
| 205 |
+
|
| 206 |
+
novelty_count = sum(
|
| 207 |
+
1 for s in self.NOVELTY_SIGNALS if s in t_lower
|
| 208 |
+
)
|
| 209 |
+
incremental_count = sum(
|
| 210 |
+
1 for s in self.INCREMENTAL_SIGNALS if s in t_lower
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
score = 0.50
|
| 214 |
+
score += novelty_count * 0.08 # each novelty signal +8%
|
| 215 |
+
score -= incremental_count * 0.06 # each incremental signal -6%
|
| 216 |
+
|
| 217 |
+
return round(min(max(score, 0.05), 0.95), 3)
|
| 218 |
+
|
| 219 |
+
# ββ literature search βββββββββββββββββββββββββββββββββββββ
|
| 220 |
+
|
| 221 |
+
def _search_literature(
|
| 222 |
+
self, key_terms: list, title: str
|
| 223 |
+
) -> tuple:
|
| 224 |
+
"""
|
| 225 |
+
Search Semantic Scholar for related papers.
|
| 226 |
+
Returns (list of RelatedWork, success_bool).
|
| 227 |
+
Free API β no key required for basic search.
|
| 228 |
+
"""
|
| 229 |
+
query_parts = key_terms[:3]
|
| 230 |
+
if title:
|
| 231 |
+
# use first 5 words of title for precision
|
| 232 |
+
title_words = title.split()[:5]
|
| 233 |
+
query_parts = title_words + query_parts[:2]
|
| 234 |
+
|
| 235 |
+
query = " ".join(query_parts)
|
| 236 |
+
encoded = urllib.parse.quote(query)
|
| 237 |
+
url = (
|
| 238 |
+
f"{self._api_base}"
|
| 239 |
+
f"?query={encoded}"
|
| 240 |
+
f"&fields=title,year,authors"
|
| 241 |
+
f"&limit=5"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
req = urllib.request.Request(
|
| 246 |
+
url,
|
| 247 |
+
headers={
|
| 248 |
+
"User-Agent": "SciPeerAI/0.1 (academic integrity tool)"
|
| 249 |
+
}
|
| 250 |
+
)
|
| 251 |
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
| 252 |
+
data = json.loads(resp.read().decode())
|
| 253 |
+
|
| 254 |
+
papers = data.get("data", [])
|
| 255 |
+
related = []
|
| 256 |
+
|
| 257 |
+
for p in papers:
|
| 258 |
+
if not p.get("title"):
|
| 259 |
+
continue
|
| 260 |
+
authors = [
|
| 261 |
+
a.get("name", "")
|
| 262 |
+
for a in p.get("authors", [])[:3]
|
| 263 |
+
]
|
| 264 |
+
# rough similarity β shared words between
|
| 265 |
+
# search query and result title
|
| 266 |
+
result_words = set(
|
| 267 |
+
p["title"].lower().split()
|
| 268 |
+
)
|
| 269 |
+
query_words = set(query.lower().split())
|
| 270 |
+
overlap = len(result_words & query_words)
|
| 271 |
+
signal = (
|
| 272 |
+
"high overlap" if overlap > 4
|
| 273 |
+
else "moderate overlap" if overlap > 2
|
| 274 |
+
else "low overlap"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
related.append(RelatedWork(
|
| 278 |
+
title=p["title"],
|
| 279 |
+
year=p.get("year", 0),
|
| 280 |
+
authors=authors,
|
| 281 |
+
similarity_signal=signal,
|
| 282 |
+
))
|
| 283 |
+
|
| 284 |
+
return related, True
|
| 285 |
+
|
| 286 |
+
except Exception:
|
| 287 |
+
# API unreachable β silent fallback
|
| 288 |
+
return [], False
|
| 289 |
+
|
| 290 |
+
# ββ flag checks βββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
|
| 292 |
+
def _check_novelty_flags(
|
| 293 |
+
self, text: str, score: float
|
| 294 |
+
) -> list:
|
| 295 |
+
"""Flag papers with suspiciously low novelty scores."""
|
| 296 |
+
flags = []
|
| 297 |
+
|
| 298 |
+
if score < 0.25:
|
| 299 |
+
flags.append(NoveltyFlag(
|
| 300 |
+
flag_type="low_novelty_score",
|
| 301 |
+
severity="high",
|
| 302 |
+
description=(
|
| 303 |
+
f"Novelty score of {round(score*100)}% suggests "
|
| 304 |
+
f"this work may be insufficiently differentiated "
|
| 305 |
+
f"from existing literature."
|
| 306 |
+
),
|
| 307 |
+
evidence=f"Novelty score: {score}",
|
| 308 |
+
suggestion=(
|
| 309 |
+
"Clearly articulate what is new in this work. "
|
| 310 |
+
"Add a dedicated section comparing to the most "
|
| 311 |
+
"closely related prior work."
|
| 312 |
+
),
|
| 313 |
+
))
|
| 314 |
+
elif score < 0.45:
|
| 315 |
+
flags.append(NoveltyFlag(
|
| 316 |
+
flag_type="moderate_novelty_concern",
|
| 317 |
+
severity="medium",
|
| 318 |
+
description=(
|
| 319 |
+
f"Novelty score of {round(score*100)}% is moderate. "
|
| 320 |
+
f"The contribution may be incremental."
|
| 321 |
+
),
|
| 322 |
+
evidence=f"Novelty score: {score}",
|
| 323 |
+
suggestion=(
|
| 324 |
+
"Strengthen the novelty argument. Be specific about "
|
| 325 |
+
"what prior work did NOT do that this paper does."
|
| 326 |
+
),
|
| 327 |
+
))
|
| 328 |
+
|
| 329 |
+
return flags
|
| 330 |
+
|
| 331 |
+
def _check_incremental_language(self, text: str) -> list:
|
| 332 |
+
"""Flag papers that heavily signal incremental work."""
|
| 333 |
+
flags = []
|
| 334 |
+
t_lower = text.lower()
|
| 335 |
+
|
| 336 |
+
found = [s for s in self.INCREMENTAL_SIGNALS if s in t_lower]
|
| 337 |
+
|
| 338 |
+
if len(found) >= 3:
|
| 339 |
+
flags.append(NoveltyFlag(
|
| 340 |
+
flag_type="incremental_language_detected",
|
| 341 |
+
severity="medium",
|
| 342 |
+
description=(
|
| 343 |
+
f"Paper uses multiple phrases suggesting incremental "
|
| 344 |
+
f"rather than novel contribution: {found[:3]}"
|
| 345 |
+
),
|
| 346 |
+
evidence=str(found[:3]),
|
| 347 |
+
suggestion=(
|
| 348 |
+
"This is not inherently a problem β incremental work "
|
| 349 |
+
"is valuable. But if claiming novelty, revise language "
|
| 350 |
+
"to clearly articulate what is genuinely new."
|
| 351 |
+
),
|
| 352 |
+
))
|
| 353 |
+
|
| 354 |
+
return flags
|
| 355 |
+
|
| 356 |
+
# ββ scoring βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 357 |
+
|
| 358 |
+
def _get_novelty_level(self, score: float) -> str:
|
| 359 |
+
if score >= 0.75: return "very_high"
|
| 360 |
+
elif score >= 0.55: return "high"
|
| 361 |
+
elif score >= 0.35: return "moderate"
|
| 362 |
+
return "low"
|
| 363 |
+
|
| 364 |
+
def _get_risk_level(self, novelty_score: float) -> str:
|
| 365 |
+
# low novelty = high risk of rejection / plagiarism concern
|
| 366 |
+
if novelty_score < 0.25: return "critical"
|
| 367 |
+
elif novelty_score < 0.40: return "high"
|
| 368 |
+
elif novelty_score < 0.60: return "medium"
|
| 369 |
+
return "low"
|
| 370 |
+
|
| 371 |
+
def _write_summary(
|
| 372 |
+
self, score: float, level: str,
|
| 373 |
+
related: list, api_ok: bool, flags: list
|
| 374 |
+
) -> str:
|
| 375 |
+
pct = round(score * 100)
|
| 376 |
+
base = f"Novelty score: {pct}% ({level.replace('_', ' ')})."
|
| 377 |
+
|
| 378 |
+
if api_ok and related:
|
| 379 |
+
base += f" {len(related)} related work(s) found in literature."
|
| 380 |
+
elif not api_ok:
|
| 381 |
+
base += " Literature search unavailable β score based on structural analysis only."
|
| 382 |
+
|
| 383 |
+
if not flags:
|
| 384 |
+
base += " No novelty concerns detected."
|
| 385 |
+
else:
|
| 386 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 387 |
+
if high:
|
| 388 |
+
base += f" {high} novelty concern(s) flagged."
|
| 389 |
+
|
| 390 |
+
return base
|
src/scipeerai/modules/pcurve_analyzer.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/scipeerai/modules/pcurve_analyzer.py
|
| 2 |
+
#
|
| 3 |
+
# P-Curve Analyzer
|
| 4 |
+
# Detects publication bias by analyzing p-value distribution.
|
| 5 |
+
# Real effects: p-values uniformly distributed 0.00-0.05
|
| 6 |
+
# P-hacking: p-values cluster just below 0.05
|
| 7 |
+
#
|
| 8 |
+
# Based on Simonsohn, Nelson & Simmons (2014)
|
| 9 |
+
# Published in Journal of Experimental Psychology
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import math
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class PCurveFlag:
|
| 18 |
+
flag_type: str
|
| 19 |
+
severity: str
|
| 20 |
+
description: str
|
| 21 |
+
evidence: str
|
| 22 |
+
suggestion: str
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class PCurveResult:
|
| 27 |
+
p_values_found: list
|
| 28 |
+
significant_p: list
|
| 29 |
+
right_skew_ratio: float
|
| 30 |
+
clustering_score: float
|
| 31 |
+
pcurve_score: float
|
| 32 |
+
risk_level: str
|
| 33 |
+
summary: str
|
| 34 |
+
flags: list = field(default_factory=list)
|
| 35 |
+
flags_count: int = 0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class PCurveAnalyzer:
|
| 39 |
+
"""
|
| 40 |
+
P-Curve Analyzer.
|
| 41 |
+
Analyzes distribution of p-values to detect
|
| 42 |
+
publication bias and p-hacking patterns.
|
| 43 |
+
|
| 44 |
+
Key insight:
|
| 45 |
+
- Real effects β p-values RIGHT skewed (more near 0.01)
|
| 46 |
+
- P-hacking β p-values cluster near 0.05
|
| 47 |
+
- No effect β p-values uniformly distributed
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
P_PATTERN = re.compile(
|
| 51 |
+
r'p\s*[=<>β€]\s*(0?\.\d+)',
|
| 52 |
+
re.IGNORECASE
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def analyze(self, text: str) -> PCurveResult:
|
| 56 |
+
all_p = self._extract_p_values(text)
|
| 57 |
+
sig_p = [p for p in all_p if p <= 0.05]
|
| 58 |
+
flags = []
|
| 59 |
+
|
| 60 |
+
if len(sig_p) < 3:
|
| 61 |
+
return PCurveResult(
|
| 62 |
+
p_values_found = all_p,
|
| 63 |
+
significant_p = sig_p,
|
| 64 |
+
right_skew_ratio = 0.0,
|
| 65 |
+
clustering_score = 0.0,
|
| 66 |
+
pcurve_score = 0.0,
|
| 67 |
+
risk_level = "low",
|
| 68 |
+
summary = (
|
| 69 |
+
f"P-Curve Analysis: {len(sig_p)} significant p-value(s) "
|
| 70 |
+
f"found. Minimum 3 required for curve analysis."
|
| 71 |
+
),
|
| 72 |
+
flags = [],
|
| 73 |
+
flags_count= 0,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
right_skew = self._right_skew_ratio(sig_p)
|
| 77 |
+
clustering = self._clustering_score(sig_p)
|
| 78 |
+
score = self._aggregate_score(right_skew, clustering, sig_p)
|
| 79 |
+
level = self._risk(score, clustering, right_skew)
|
| 80 |
+
|
| 81 |
+
# ββ Flag 1: P-value clustering near 0.05 βββββββββββββββββ
|
| 82 |
+
if clustering > 0.5:
|
| 83 |
+
near_05 = sum(1 for p in sig_p if p >= 0.04)
|
| 84 |
+
flags.append(PCurveFlag(
|
| 85 |
+
flag_type = "p_value_clustering",
|
| 86 |
+
severity = "high" if clustering > 0.7 else "medium",
|
| 87 |
+
description = (
|
| 88 |
+
f"{near_05}/{len(sig_p)} significant p-values "
|
| 89 |
+
f"({round(near_05/len(sig_p)*100)}%) fall between "
|
| 90 |
+
f"0.040-0.050. This clustering pattern is the "
|
| 91 |
+
f"hallmark of p-hacking β results were likely "
|
| 92 |
+
f"manipulated to just reach significance."
|
| 93 |
+
),
|
| 94 |
+
evidence = (
|
| 95 |
+
f"Significant p-values: {[round(p,4) for p in sig_p]} | "
|
| 96 |
+
f"Near-0.05 ratio: {round(clustering*100)}%"
|
| 97 |
+
),
|
| 98 |
+
suggestion = (
|
| 99 |
+
"Pre-register hypotheses before data collection. "
|
| 100 |
+
"Report all tests conducted including non-significant. "
|
| 101 |
+
"Use sequential testing or Bayesian methods."
|
| 102 |
+
),
|
| 103 |
+
))
|
| 104 |
+
|
| 105 |
+
# ββ Flag 2: Lack of right skew (no real effect) βββββββββββ
|
| 106 |
+
if right_skew < 0.3 and len(sig_p) >= 4:
|
| 107 |
+
flags.append(PCurveFlag(
|
| 108 |
+
flag_type = "flat_pcurve",
|
| 109 |
+
severity = "medium",
|
| 110 |
+
description = (
|
| 111 |
+
f"P-curve lacks right skew β only {round(right_skew*100)}% "
|
| 112 |
+
f"of p-values fall below 0.025. A genuine effect "
|
| 113 |
+
f"produces a right-skewed p-curve. Flat curve suggests "
|
| 114 |
+
f"the findings may lack evidentiary value."
|
| 115 |
+
),
|
| 116 |
+
evidence = (
|
| 117 |
+
f"Right-skew ratio: {round(right_skew*100)}% "
|
| 118 |
+
f"(expected >50% for real effects) | "
|
| 119 |
+
f"P-values: {[round(p,4) for p in sig_p]}"
|
| 120 |
+
),
|
| 121 |
+
suggestion = (
|
| 122 |
+
"Conduct a power analysis. If the effect is real, "
|
| 123 |
+
"p-values should skew toward 0. Consider increasing "
|
| 124 |
+
"sample size for a more definitive test."
|
| 125 |
+
),
|
| 126 |
+
))
|
| 127 |
+
|
| 128 |
+
# ββ Flag 3: Too many exactly 0.05 values ββββββββββββββββββ
|
| 129 |
+
exact_05 = sum(1 for p in all_p if abs(p - 0.05) < 0.001)
|
| 130 |
+
if exact_05 >= 2:
|
| 131 |
+
flags.append(PCurveFlag(
|
| 132 |
+
flag_type = "exact_threshold_reporting",
|
| 133 |
+
severity = "medium",
|
| 134 |
+
description = (
|
| 135 |
+
f"{exact_05} p-values reported as exactly p=0.05. "
|
| 136 |
+
f"This is statistically rare in real data and "
|
| 137 |
+
f"suggests threshold-seeking behavior or rounding."
|
| 138 |
+
),
|
| 139 |
+
evidence = (
|
| 140 |
+
f"{exact_05} values equal to exactly 0.050 found"
|
| 141 |
+
),
|
| 142 |
+
suggestion = (
|
| 143 |
+
"Report exact p-values to 3+ decimal places. "
|
| 144 |
+
"Avoid rounding to threshold values."
|
| 145 |
+
),
|
| 146 |
+
))
|
| 147 |
+
|
| 148 |
+
summary = self._build_summary(
|
| 149 |
+
all_p, sig_p, score, level,
|
| 150 |
+
right_skew, clustering
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
return PCurveResult(
|
| 154 |
+
p_values_found = all_p,
|
| 155 |
+
significant_p = sig_p,
|
| 156 |
+
right_skew_ratio = round(right_skew, 4),
|
| 157 |
+
clustering_score = round(clustering, 4),
|
| 158 |
+
pcurve_score = round(score, 4),
|
| 159 |
+
risk_level = level,
|
| 160 |
+
summary = summary,
|
| 161 |
+
flags = flags,
|
| 162 |
+
flags_count = len(flags),
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# ββ internal helpers βββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
|
| 167 |
+
def _extract_p_values(self, text: str) -> list:
|
| 168 |
+
values = []
|
| 169 |
+
for m in self.P_PATTERN.finditer(text):
|
| 170 |
+
try:
|
| 171 |
+
v = float(m.group(1))
|
| 172 |
+
if 0 < v <= 1:
|
| 173 |
+
values.append(round(v, 4))
|
| 174 |
+
except ValueError:
|
| 175 |
+
pass
|
| 176 |
+
return values
|
| 177 |
+
|
| 178 |
+
def _right_skew_ratio(self, sig_p: list) -> float:
|
| 179 |
+
"""
|
| 180 |
+
Ratio of p-values below 0.025 vs 0.025-0.05.
|
| 181 |
+
Real effects: >50% below 0.025 (right skewed).
|
| 182 |
+
"""
|
| 183 |
+
if not sig_p:
|
| 184 |
+
return 0.0
|
| 185 |
+
below_half = sum(1 for p in sig_p if p <= 0.025)
|
| 186 |
+
return below_half / len(sig_p)
|
| 187 |
+
|
| 188 |
+
def _clustering_score(self, sig_p: list) -> float:
|
| 189 |
+
"""
|
| 190 |
+
Ratio of p-values in 0.04-0.05 range.
|
| 191 |
+
High clustering = p-hacking signature.
|
| 192 |
+
"""
|
| 193 |
+
if not sig_p:
|
| 194 |
+
return 0.0
|
| 195 |
+
near_05 = sum(1 for p in sig_p if p >= 0.04)
|
| 196 |
+
return near_05 / len(sig_p)
|
| 197 |
+
|
| 198 |
+
def _aggregate_score(self, right_skew: float,
|
| 199 |
+
clustering: float,
|
| 200 |
+
sig_p: list) -> float:
|
| 201 |
+
"""Combine signals into 0-1 risk score."""
|
| 202 |
+
cluster_risk = clustering
|
| 203 |
+
no_skew_risk = 1.0 - right_skew
|
| 204 |
+
score = (cluster_risk * 0.6 + no_skew_risk * 0.4)
|
| 205 |
+
return min(round(score, 4), 1.0)
|
| 206 |
+
|
| 207 |
+
def _risk(self, score: float,
|
| 208 |
+
clustering: float,
|
| 209 |
+
right_skew: float) -> str:
|
| 210 |
+
if clustering > 0.7 or score >= 0.7:
|
| 211 |
+
return "critical"
|
| 212 |
+
if clustering > 0.5 or score >= 0.5:
|
| 213 |
+
return "high"
|
| 214 |
+
if clustering > 0.3 or score >= 0.3:
|
| 215 |
+
return "medium"
|
| 216 |
+
return "low"
|
| 217 |
+
|
| 218 |
+
def _build_summary(self, all_p, sig_p, score,
|
| 219 |
+
level, right_skew, clustering) -> str:
|
| 220 |
+
pct = round(score * 100)
|
| 221 |
+
return (
|
| 222 |
+
f"P-Curve analyzed {len(all_p)} p-value(s), "
|
| 223 |
+
f"{len(sig_p)} significant (pβ€0.05). "
|
| 224 |
+
f"Clustering score: {round(clustering*100)}% near p=0.05. "
|
| 225 |
+
f"Right-skew ratio: {round(right_skew*100)}%. "
|
| 226 |
+
f"Overall bias score: {pct}%. "
|
| 227 |
+
f"Risk level: {level.upper()}."
|
| 228 |
+
)
|
src/scipeerai/modules/peer_review_score.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Peer Review Manipulation Score
|
| 2 |
+
# --------------------------------
|
| 3 |
+
# Peer review is the last defense before bad science
|
| 4 |
+
# enters the literature. When that defense is gamed,
|
| 5 |
+
# the consequences propagate for decades.
|
| 6 |
+
#
|
| 7 |
+
# Manipulation takes several forms:
|
| 8 |
+
# Authors suggest reviewers who are actually collaborators.
|
| 9 |
+
# Papers get accepted in days when months is the norm.
|
| 10 |
+
# Editors handle papers from their own institution.
|
| 11 |
+
# Authors cite the editor excessively before submission.
|
| 12 |
+
# Review reports are suspiciously short or praise-only.
|
| 13 |
+
#
|
| 14 |
+
# Most of these signals live in metadata β journal names,
|
| 15 |
+
# dates, acknowledgments, conflict statements.
|
| 16 |
+
# This module reads what is available in the paper text
|
| 17 |
+
# and scores the manipulation risk from those signals.
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from datetime import datetime
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ββ constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
CURRENT_YEAR = datetime.now().year
|
| 27 |
+
|
| 28 |
+
# phrases that signal a potentially compromised review process
|
| 29 |
+
FAST_ACCEPTANCE_MARKERS = [
|
| 30 |
+
r'received[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
|
| 31 |
+
r'accepted[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
|
| 32 |
+
r'submitted[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
|
| 33 |
+
r'revised[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
MONTH_ORDER = {
|
| 37 |
+
"january": 1, "february": 2, "march": 3, "april": 4,
|
| 38 |
+
"may": 5, "june": 6, "july": 7, "august": 8,
|
| 39 |
+
"september": 9, "october": 10, "november": 11, "december": 12,
|
| 40 |
+
"jan": 1, "feb": 2, "mar": 3, "apr": 4,
|
| 41 |
+
"jun": 6, "jul": 7, "aug": 8, "sep": 9,
|
| 42 |
+
"oct": 10, "nov": 11, "dec": 12,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# phrases that reveal undisclosed relationships
|
| 46 |
+
CONFLICT_RED_FLAGS = [
|
| 47 |
+
"no conflict", "no competing interest", "no financial interest",
|
| 48 |
+
"authors declare no", "declare no conflict",
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
# phrases suggesting reviewer suggestions were made
|
| 52 |
+
REVIEWER_SUGGESTION_MARKERS = [
|
| 53 |
+
"suggested reviewer", "recommended reviewer",
|
| 54 |
+
"proposed reviewer", "reviewer suggestion",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
# special issue markers β these have lighter review
|
| 58 |
+
SPECIAL_ISSUE_MARKERS = [
|
| 59 |
+
"special issue", "invited paper", "invited article",
|
| 60 |
+
"guest editor", "symposium paper", "conference paper",
|
| 61 |
+
"extended version", "workshop paper",
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
# predatory journal signals in acknowledgment or journal name context
|
| 65 |
+
PREDATORY_SIGNALS = [
|
| 66 |
+
"article processing charge", "apc waiver", "rapid publication",
|
| 67 |
+
"fast track", "open access fee", "publication fee waived",
|
| 68 |
+
"guaranteed acceptance", "no peer review", "editorial board member",
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ββ data structures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
|
| 74 |
+
@dataclass
|
| 75 |
+
class ReviewFlag:
|
| 76 |
+
flag_type: str
|
| 77 |
+
severity: str
|
| 78 |
+
description: str
|
| 79 |
+
evidence: str
|
| 80 |
+
suggestion: str
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass
|
| 84 |
+
class PeerReviewResult:
|
| 85 |
+
days_to_acceptance: int
|
| 86 |
+
has_conflict_statement: bool
|
| 87 |
+
conflict_credible: bool
|
| 88 |
+
reviewer_suggestions: bool
|
| 89 |
+
special_issue: bool
|
| 90 |
+
predatory_signals: int
|
| 91 |
+
self_citation_of_editor: float
|
| 92 |
+
manipulation_score: float
|
| 93 |
+
risk_level: str
|
| 94 |
+
summary: str
|
| 95 |
+
flags: list
|
| 96 |
+
flags_count: int
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ββ main class ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
class PeerReviewScorer:
|
| 102 |
+
"""
|
| 103 |
+
Scores the risk that a paper's peer review process was manipulated.
|
| 104 |
+
|
| 105 |
+
Six detection layers:
|
| 106 |
+
1. Unusually fast acceptance timeline
|
| 107 |
+
2. Missing or boilerplate conflict of interest statement
|
| 108 |
+
3. Reviewer suggestion language in the text
|
| 109 |
+
4. Special issue / invited paper bypass signals
|
| 110 |
+
5. Predatory journal process markers
|
| 111 |
+
6. Excessive self-citation patterns suggesting editor citation farming
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
def analyze(self, text: str) -> PeerReviewResult:
|
| 115 |
+
flags = []
|
| 116 |
+
|
| 117 |
+
days = self._measure_acceptance_speed(text, flags)
|
| 118 |
+
has_conflict = self._check_conflict_statement(text)
|
| 119 |
+
credible_conf = self._check_conflict_credibility(text, has_conflict, flags)
|
| 120 |
+
reviewer_sugg = self._check_reviewer_suggestions(text, flags)
|
| 121 |
+
special = self._check_special_issue(text, flags)
|
| 122 |
+
predatory = self._check_predatory_signals(text, flags)
|
| 123 |
+
editor_cite = self._check_editor_self_citation(text, flags)
|
| 124 |
+
|
| 125 |
+
score = self._compute_score(
|
| 126 |
+
days, has_conflict, credible_conf,
|
| 127 |
+
reviewer_sugg, special, predatory, editor_cite
|
| 128 |
+
)
|
| 129 |
+
risk = self._get_risk_level(score)
|
| 130 |
+
|
| 131 |
+
return PeerReviewResult(
|
| 132 |
+
days_to_acceptance = days,
|
| 133 |
+
has_conflict_statement = has_conflict,
|
| 134 |
+
conflict_credible = credible_conf,
|
| 135 |
+
reviewer_suggestions = reviewer_sugg,
|
| 136 |
+
special_issue = special,
|
| 137 |
+
predatory_signals = predatory,
|
| 138 |
+
self_citation_of_editor = round(editor_cite, 3),
|
| 139 |
+
manipulation_score = round(score, 3),
|
| 140 |
+
risk_level = risk,
|
| 141 |
+
summary = self._write_summary(flags, risk, days),
|
| 142 |
+
flags = flags,
|
| 143 |
+
flags_count = len(flags),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# ββ timeline analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 147 |
+
|
| 148 |
+
def _measure_acceptance_speed(self, text: str, flags: list) -> int:
|
| 149 |
+
"""
|
| 150 |
+
Extract submission and acceptance dates and compute the gap in days.
|
| 151 |
+
Legitimate peer review takes 30-180 days on average.
|
| 152 |
+
Under 14 days is a strong manipulation signal.
|
| 153 |
+
Under 7 days is near-certain manipulation or editorial bypass.
|
| 154 |
+
"""
|
| 155 |
+
dates = {}
|
| 156 |
+
text_lo = text.lower()
|
| 157 |
+
|
| 158 |
+
for marker_type in ["received", "accepted", "submitted", "revised"]:
|
| 159 |
+
pattern = rf'{marker_type}[:\s]+([a-z]+\s+\d{{1,2}},?\s+\d{{4}}|\d{{1,2}}\s+[a-z]+\s+\d{{4}})'
|
| 160 |
+
match = re.search(pattern, text_lo)
|
| 161 |
+
if match:
|
| 162 |
+
parsed = self._parse_date_string(match.group(1))
|
| 163 |
+
if parsed:
|
| 164 |
+
dates[marker_type] = parsed
|
| 165 |
+
|
| 166 |
+
submit_key = "received" if "received" in dates else "submitted"
|
| 167 |
+
accept_key = "accepted"
|
| 168 |
+
|
| 169 |
+
if submit_key not in dates or accept_key not in dates:
|
| 170 |
+
return -1
|
| 171 |
+
|
| 172 |
+
delta = (dates[accept_key] - dates[submit_key]).days
|
| 173 |
+
|
| 174 |
+
if delta < 0:
|
| 175 |
+
return -1
|
| 176 |
+
|
| 177 |
+
if delta <= 7:
|
| 178 |
+
flags.append(ReviewFlag(
|
| 179 |
+
flag_type = "instant_acceptance",
|
| 180 |
+
severity = "high",
|
| 181 |
+
description = (
|
| 182 |
+
f"Paper accepted in {delta} day(s) β "
|
| 183 |
+
f"faster than any legitimate peer review process. "
|
| 184 |
+
f"Standard review takes 30-180 days. "
|
| 185 |
+
f"This timeline indicates editorial bypass or "
|
| 186 |
+
f"pre-arranged acceptance."
|
| 187 |
+
),
|
| 188 |
+
evidence = (
|
| 189 |
+
f"Submission to acceptance gap: {delta} days. "
|
| 190 |
+
f"Dates extracted from paper header."
|
| 191 |
+
),
|
| 192 |
+
suggestion = (
|
| 193 |
+
"Verify the review timeline against the journal's "
|
| 194 |
+
"published average review duration. Report to the "
|
| 195 |
+
"editorial board if discrepancy is confirmed."
|
| 196 |
+
),
|
| 197 |
+
))
|
| 198 |
+
elif delta <= 14:
|
| 199 |
+
flags.append(ReviewFlag(
|
| 200 |
+
flag_type = "unusually_fast_acceptance",
|
| 201 |
+
severity = "medium",
|
| 202 |
+
description = (
|
| 203 |
+
f"Paper accepted in {delta} days β "
|
| 204 |
+
f"significantly below the typical 30-180 day range. "
|
| 205 |
+
f"This speed is consistent with compromised review."
|
| 206 |
+
),
|
| 207 |
+
evidence = f"Submission to acceptance gap: {delta} days.",
|
| 208 |
+
suggestion = (
|
| 209 |
+
"Cross-check with the journal's stated review timeline."
|
| 210 |
+
),
|
| 211 |
+
))
|
| 212 |
+
|
| 213 |
+
return max(delta, 0)
|
| 214 |
+
|
| 215 |
+
def _parse_date_string(self, raw: str):
|
| 216 |
+
"""Parse common academic date formats into a datetime object."""
|
| 217 |
+
raw = raw.strip().lower().replace(",", "")
|
| 218 |
+
parts = raw.split()
|
| 219 |
+
|
| 220 |
+
if len(parts) < 3:
|
| 221 |
+
return None
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
# format: "january 15 2023" or "15 january 2023"
|
| 225 |
+
if parts[0].isdigit():
|
| 226 |
+
day = int(parts[0])
|
| 227 |
+
month = MONTH_ORDER.get(parts[1], 0)
|
| 228 |
+
year = int(parts[2])
|
| 229 |
+
else:
|
| 230 |
+
month = MONTH_ORDER.get(parts[0], 0)
|
| 231 |
+
day = int(parts[1])
|
| 232 |
+
year = int(parts[2])
|
| 233 |
+
|
| 234 |
+
if not month or not (1 <= day <= 31) or not (2000 <= year <= 2030):
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
return datetime(year, month, day)
|
| 238 |
+
except (ValueError, IndexError):
|
| 239 |
+
return None
|
| 240 |
+
|
| 241 |
+
# ββ conflict of interest βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
+
|
| 243 |
+
def _check_conflict_statement(self, text: str) -> bool:
|
| 244 |
+
"""Check whether any conflict of interest statement exists."""
|
| 245 |
+
text_lo = text.lower()
|
| 246 |
+
return any(phrase in text_lo for phrase in CONFLICT_RED_FLAGS + [
|
| 247 |
+
"conflict of interest", "competing interest",
|
| 248 |
+
"financial disclosure", "funding source",
|
| 249 |
+
])
|
| 250 |
+
|
| 251 |
+
def _check_conflict_credibility(
|
| 252 |
+
self, text: str, has_conflict: bool, flags: list
|
| 253 |
+
) -> bool:
|
| 254 |
+
"""
|
| 255 |
+
A boilerplate 'no conflicts' statement with no specific details
|
| 256 |
+
is less credible than a detailed disclosure.
|
| 257 |
+
Missing conflict statement is always a flag in funded research.
|
| 258 |
+
"""
|
| 259 |
+
text_lo = text.lower()
|
| 260 |
+
|
| 261 |
+
has_funding = any(w in text_lo for w in [
|
| 262 |
+
"funded by", "supported by", "grant", "foundation",
|
| 263 |
+
"ministry", "national science", "european research",
|
| 264 |
+
])
|
| 265 |
+
|
| 266 |
+
if not has_conflict and has_funding:
|
| 267 |
+
flags.append(ReviewFlag(
|
| 268 |
+
flag_type = "missing_conflict_statement",
|
| 269 |
+
severity = "medium",
|
| 270 |
+
description = (
|
| 271 |
+
"Funding acknowledgment detected but no conflict of "
|
| 272 |
+
"interest statement found. Most journals require authors "
|
| 273 |
+
"to declare potential conflicts when external funding exists."
|
| 274 |
+
),
|
| 275 |
+
evidence = "Funding mention present; conflict statement absent.",
|
| 276 |
+
suggestion = (
|
| 277 |
+
"Add a conflict of interest section disclosing all "
|
| 278 |
+
"funding sources and potential financial relationships."
|
| 279 |
+
),
|
| 280 |
+
))
|
| 281 |
+
return False
|
| 282 |
+
|
| 283 |
+
boilerplate_only = has_conflict and not any(w in text_lo for w in [
|
| 284 |
+
"received funding", "consultant", "honorarium", "stock",
|
| 285 |
+
"employee of", "board member", "owns shares",
|
| 286 |
+
])
|
| 287 |
+
|
| 288 |
+
return not boilerplate_only
|
| 289 |
+
|
| 290 |
+
# ββ reviewer manipulation ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 291 |
+
|
| 292 |
+
def _check_reviewer_suggestions(self, text: str, flags: list) -> bool:
|
| 293 |
+
"""
|
| 294 |
+
Some journals allow authors to suggest reviewers.
|
| 295 |
+
When this language appears in the paper itself, it may indicate
|
| 296 |
+
that the suggestion process was leveraged inappropriately.
|
| 297 |
+
"""
|
| 298 |
+
text_lo = text.lower()
|
| 299 |
+
found = any(m in text_lo for m in REVIEWER_SUGGESTION_MARKERS)
|
| 300 |
+
|
| 301 |
+
if found:
|
| 302 |
+
flags.append(ReviewFlag(
|
| 303 |
+
flag_type = "reviewer_suggestion_language",
|
| 304 |
+
severity = "low",
|
| 305 |
+
description = (
|
| 306 |
+
"The paper contains language related to reviewer suggestions. "
|
| 307 |
+
"While suggesting reviewers is legitimate, it becomes "
|
| 308 |
+
"problematic when suggested reviewers are undisclosed "
|
| 309 |
+
"collaborators or colleagues."
|
| 310 |
+
),
|
| 311 |
+
evidence = "Reviewer suggestion language detected in paper text.",
|
| 312 |
+
suggestion = (
|
| 313 |
+
"Verify that suggested reviewers had no prior relationship "
|
| 314 |
+
"with the authors within the past 5 years."
|
| 315 |
+
),
|
| 316 |
+
))
|
| 317 |
+
|
| 318 |
+
return found
|
| 319 |
+
|
| 320 |
+
# ββ special issue bypass βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 321 |
+
|
| 322 |
+
def _check_special_issue(self, text: str, flags: list) -> bool:
|
| 323 |
+
"""
|
| 324 |
+
Special issues and invited papers often undergo lighter review.
|
| 325 |
+
This is not inherently fraudulent but reduces scrutiny significantly.
|
| 326 |
+
"""
|
| 327 |
+
text_lo = text.lower()
|
| 328 |
+
found = any(m in text_lo for m in SPECIAL_ISSUE_MARKERS)
|
| 329 |
+
|
| 330 |
+
if found:
|
| 331 |
+
flags.append(ReviewFlag(
|
| 332 |
+
flag_type = "special_issue_bypass",
|
| 333 |
+
severity = "low",
|
| 334 |
+
description = (
|
| 335 |
+
"Paper appears to be a special issue or invited submission. "
|
| 336 |
+
"These papers often receive expedited or reduced peer review, "
|
| 337 |
+
"lowering the scrutiny applied to methodology and data."
|
| 338 |
+
),
|
| 339 |
+
evidence = "Special issue or invited paper language detected.",
|
| 340 |
+
suggestion = (
|
| 341 |
+
"Apply the same rigor to special issue papers as to "
|
| 342 |
+
"regular submissions when evaluating integrity."
|
| 343 |
+
),
|
| 344 |
+
))
|
| 345 |
+
|
| 346 |
+
return found
|
| 347 |
+
|
| 348 |
+
# ββ predatory signals ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 349 |
+
|
| 350 |
+
def _check_predatory_signals(self, text: str, flags: list) -> int:
|
| 351 |
+
"""
|
| 352 |
+
Predatory journals guarantee acceptance, charge fees without
|
| 353 |
+
providing genuine review, and compromise the integrity of the
|
| 354 |
+
entire publication. Their language patterns are distinctive.
|
| 355 |
+
"""
|
| 356 |
+
text_lo = text.lower()
|
| 357 |
+
found = [s for s in PREDATORY_SIGNALS if s in text_lo]
|
| 358 |
+
|
| 359 |
+
if len(found) >= 2:
|
| 360 |
+
flags.append(ReviewFlag(
|
| 361 |
+
flag_type = "predatory_journal_signals",
|
| 362 |
+
severity = "high",
|
| 363 |
+
description = (
|
| 364 |
+
f"{len(found)} predatory journal marker(s) detected. "
|
| 365 |
+
f"These signals suggest the paper may have been published "
|
| 366 |
+
f"in a venue that does not conduct genuine peer review."
|
| 367 |
+
),
|
| 368 |
+
evidence = f"Markers found: {found[:4]}.",
|
| 369 |
+
suggestion = (
|
| 370 |
+
"Verify the journal's legitimacy using Beall's list, "
|
| 371 |
+
"DOAJ whitelist, or Cabell's Journalytics before "
|
| 372 |
+
"citing or building on this work."
|
| 373 |
+
),
|
| 374 |
+
))
|
| 375 |
+
elif len(found) == 1:
|
| 376 |
+
flags.append(ReviewFlag(
|
| 377 |
+
flag_type = "possible_predatory_signal",
|
| 378 |
+
severity = "low",
|
| 379 |
+
description = (
|
| 380 |
+
f"One predatory journal marker detected: '{found[0]}'. "
|
| 381 |
+
f"Alone this is not conclusive, but warrants verification."
|
| 382 |
+
),
|
| 383 |
+
evidence = f"Marker: {found[0]}.",
|
| 384 |
+
suggestion = "Verify journal legitimacy.",
|
| 385 |
+
))
|
| 386 |
+
|
| 387 |
+
return len(found)
|
| 388 |
+
|
| 389 |
+
# ββ editor self-citation βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 390 |
+
|
| 391 |
+
def _check_editor_self_citation(self, text: str, flags: list) -> float:
|
| 392 |
+
"""
|
| 393 |
+
Some authors identify the handling editor and then cite them
|
| 394 |
+
heavily β a known strategy to guarantee favorable review.
|
| 395 |
+
We measure citation density around editor-identification language.
|
| 396 |
+
"""
|
| 397 |
+
text_lo = text.lower()
|
| 398 |
+
editor_markers = ["editor", "handling editor", "associate editor", "guest editor"]
|
| 399 |
+
has_editor = any(m in text_lo for m in editor_markers)
|
| 400 |
+
|
| 401 |
+
if not has_editor:
|
| 402 |
+
return 0.0
|
| 403 |
+
|
| 404 |
+
# count citations in a window around editor mentions
|
| 405 |
+
total_citations = len(re.findall(
|
| 406 |
+
r'[A-Z][a-z]+\s+\(\d{4}\)', text
|
| 407 |
+
))
|
| 408 |
+
|
| 409 |
+
if total_citations == 0:
|
| 410 |
+
return 0.0
|
| 411 |
+
|
| 412 |
+
# citations within 500 chars of an editor mention
|
| 413 |
+
editor_adjacent = 0
|
| 414 |
+
for marker in editor_markers:
|
| 415 |
+
idx = text_lo.find(marker)
|
| 416 |
+
while idx != -1:
|
| 417 |
+
window = text[max(0, idx - 250): idx + 250]
|
| 418 |
+
editor_adjacent += len(re.findall(
|
| 419 |
+
r'[A-Z][a-z]+\s+\(\d{4}\)', window
|
| 420 |
+
))
|
| 421 |
+
idx = text_lo.find(marker, idx + 1)
|
| 422 |
+
|
| 423 |
+
ratio = min(editor_adjacent / max(total_citations, 1), 1.0)
|
| 424 |
+
|
| 425 |
+
if ratio >= 0.30:
|
| 426 |
+
flags.append(ReviewFlag(
|
| 427 |
+
flag_type = "editor_citation_concentration",
|
| 428 |
+
severity = "medium",
|
| 429 |
+
description = (
|
| 430 |
+
f"{round(ratio * 100, 1)}% of citations cluster "
|
| 431 |
+
f"near editor-identification language. "
|
| 432 |
+
f"Concentrating citations around the editor is a "
|
| 433 |
+
f"known strategy to incentivize favorable review."
|
| 434 |
+
),
|
| 435 |
+
evidence = (
|
| 436 |
+
f"Editor mentions: present. "
|
| 437 |
+
f"Citations near editor language: {editor_adjacent} "
|
| 438 |
+
f"of {total_citations} total."
|
| 439 |
+
),
|
| 440 |
+
suggestion = (
|
| 441 |
+
"Ensure citations reflect genuine intellectual debt "
|
| 442 |
+
"rather than reviewer management strategy."
|
| 443 |
+
),
|
| 444 |
+
))
|
| 445 |
+
|
| 446 |
+
return ratio
|
| 447 |
+
|
| 448 |
+
# ββ scoring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 449 |
+
|
| 450 |
+
def _compute_score(
|
| 451 |
+
self,
|
| 452 |
+
days: int,
|
| 453 |
+
has_conflict: bool,
|
| 454 |
+
credible_conf: bool,
|
| 455 |
+
reviewer_sugg: bool,
|
| 456 |
+
special: bool,
|
| 457 |
+
predatory: int,
|
| 458 |
+
editor_cite: float,
|
| 459 |
+
) -> float:
|
| 460 |
+
score = 0.0
|
| 461 |
+
|
| 462 |
+
if days >= 0:
|
| 463 |
+
if days <= 7:
|
| 464 |
+
score += 0.40
|
| 465 |
+
elif days <= 14:
|
| 466 |
+
score += 0.25
|
| 467 |
+
elif days <= 21:
|
| 468 |
+
score += 0.10
|
| 469 |
+
|
| 470 |
+
if not has_conflict:
|
| 471 |
+
score += 0.15
|
| 472 |
+
elif not credible_conf:
|
| 473 |
+
score += 0.08
|
| 474 |
+
|
| 475 |
+
if reviewer_sugg:
|
| 476 |
+
score += 0.08
|
| 477 |
+
if special:
|
| 478 |
+
score += 0.05
|
| 479 |
+
if predatory >= 2:
|
| 480 |
+
score += 0.30
|
| 481 |
+
elif predatory == 1:
|
| 482 |
+
score += 0.10
|
| 483 |
+
|
| 484 |
+
score += editor_cite * 0.15
|
| 485 |
+
|
| 486 |
+
return min(score, 1.0)
|
| 487 |
+
|
| 488 |
+
def _get_risk_level(self, score: float) -> str:
|
| 489 |
+
if score >= 0.70: return "critical"
|
| 490 |
+
if score >= 0.45: return "high"
|
| 491 |
+
if score >= 0.20: return "medium"
|
| 492 |
+
return "low"
|
| 493 |
+
|
| 494 |
+
def _write_summary(
|
| 495 |
+
self, flags: list, risk_level: str, days: int
|
| 496 |
+
) -> str:
|
| 497 |
+
timeline = ""
|
| 498 |
+
if days >= 0:
|
| 499 |
+
timeline = f" Acceptance timeline: {days} days."
|
| 500 |
+
|
| 501 |
+
if not flags:
|
| 502 |
+
return (
|
| 503 |
+
f"Peer Review Analysis: No manipulation signals detected."
|
| 504 |
+
f"{timeline} Review process appears standard. "
|
| 505 |
+
f"Risk level: {risk_level.upper()}."
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
high = sum(1 for f in flags if f.severity == "high")
|
| 509 |
+
medium = sum(1 for f in flags if f.severity == "medium")
|
| 510 |
+
low = sum(1 for f in flags if f.severity == "low")
|
| 511 |
+
parts = []
|
| 512 |
+
if high:
|
| 513 |
+
parts.append(f"{high} high-severity signal{'s' if high > 1 else ''}")
|
| 514 |
+
if medium:
|
| 515 |
+
parts.append(f"{medium} medium concern{'s' if medium > 1 else ''}")
|
| 516 |
+
if low:
|
| 517 |
+
parts.append(f"{low} low-severity indicator{'s' if low > 1 else ''}")
|
| 518 |
+
|
| 519 |
+
return (
|
| 520 |
+
f"Peer Review Analysis: {', '.join(parts)} detected."
|
| 521 |
+
f"{timeline} Risk level: {risk_level.upper()}."
|
| 522 |
+
)
|