aivolcano commited on
Commit ·
3d83b62
1
Parent(s): ec7bc6b
FastAPI + Gradio + src
Browse files- .dockerignore +84 -0
- .env.example +44 -0
- CiteScan +1 -0
- Dockerfile +37 -0
- README.md +271 -38
- docker-compose.yml +50 -0
- main.py +115 -0
- scripts/push_to_hf.sh +44 -0
- src/api/__init__.py +1 -0
- src/api/dependencies.py +29 -0
- src/api/routes/__init__.py +1 -0
- src/api/routes/health.py +54 -0
- src/api/routes/verification.py +143 -0
- src/api/schemas/__init__.py +94 -0
- src/core/__init__.py +21 -0
- src/core/cache.py +163 -0
- src/core/config.py +88 -0
- src/core/exceptions.py +74 -0
- src/core/logging.py +107 -0
- src/services/__init__.py +4 -0
- src/services/verification_service.py +273 -0
- start.sh +38 -0
.dockerignore
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
MANIFEST
|
| 23 |
+
|
| 24 |
+
# Virtual Environments
|
| 25 |
+
venv/
|
| 26 |
+
env/
|
| 27 |
+
.env
|
| 28 |
+
.venv/
|
| 29 |
+
|
| 30 |
+
# IDEs
|
| 31 |
+
.idea/
|
| 32 |
+
.vscode/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
|
| 36 |
+
# macOS
|
| 37 |
+
.DS_Store
|
| 38 |
+
.AppleDouble
|
| 39 |
+
.LSOverride
|
| 40 |
+
|
| 41 |
+
# Project Specific Outputs
|
| 42 |
+
*.md
|
| 43 |
+
!README.md
|
| 44 |
+
*_only_used_entry.bib
|
| 45 |
+
|
| 46 |
+
# LaTeX and Bibliography (User Data)
|
| 47 |
+
*.tex
|
| 48 |
+
*.bib
|
| 49 |
+
*.pdf
|
| 50 |
+
*.aux
|
| 51 |
+
*.out
|
| 52 |
+
*.bbl
|
| 53 |
+
*.blg
|
| 54 |
+
*.synctex.gz
|
| 55 |
+
*.fls
|
| 56 |
+
*.fdb_latexmk
|
| 57 |
+
|
| 58 |
+
# cache
|
| 59 |
+
.cache
|
| 60 |
+
|
| 61 |
+
# Logs
|
| 62 |
+
logs/
|
| 63 |
+
*.log
|
| 64 |
+
|
| 65 |
+
# Gradio
|
| 66 |
+
.gradio/
|
| 67 |
+
|
| 68 |
+
# Environment variables
|
| 69 |
+
.env
|
| 70 |
+
.env.local
|
| 71 |
+
.env.*.local
|
| 72 |
+
|
| 73 |
+
# Test coverage
|
| 74 |
+
.coverage
|
| 75 |
+
htmlcov/
|
| 76 |
+
.pytest_cache/
|
| 77 |
+
|
| 78 |
+
# Temporary files
|
| 79 |
+
*.tmp
|
| 80 |
+
*.temp
|
| 81 |
+
temp/
|
| 82 |
+
|
| 83 |
+
# Docker
|
| 84 |
+
.dockerignore
|
.env.example
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Application Settings
|
| 2 |
+
APP_NAME=CiteScan
|
| 3 |
+
APP_VERSION=1.0.0
|
| 4 |
+
ENVIRONMENT=development # development, staging, production
|
| 5 |
+
|
| 6 |
+
# Server Configuration
|
| 7 |
+
API_HOST=0.0.0.0
|
| 8 |
+
API_PORT=8000
|
| 9 |
+
GRADIO_HOST=0.0.0.0
|
| 10 |
+
GRADIO_PORT=7860
|
| 11 |
+
|
| 12 |
+
# API Rate Limiting
|
| 13 |
+
RATE_LIMIT_ENABLED=true
|
| 14 |
+
RATE_LIMIT_REQUESTS=100
|
| 15 |
+
RATE_LIMIT_PERIOD=60 # seconds
|
| 16 |
+
|
| 17 |
+
# Cache Configuration
|
| 18 |
+
CACHE_ENABLED=true
|
| 19 |
+
CACHE_TTL=3600 # seconds (1 hour)
|
| 20 |
+
CACHE_MAX_SIZE=1000 # max number of cached items
|
| 21 |
+
|
| 22 |
+
# Fetcher Configuration
|
| 23 |
+
ARXIV_RATE_LIMIT_DELAY=3.0 # seconds between requests
|
| 24 |
+
CROSSREF_RATE_LIMIT_DELAY=1.0
|
| 25 |
+
SEMANTIC_SCHOLAR_RATE_LIMIT_DELAY=1.0
|
| 26 |
+
DBLP_RATE_LIMIT_DELAY=1.0
|
| 27 |
+
OPENALEX_RATE_LIMIT_DELAY=1.0
|
| 28 |
+
SCHOLAR_RATE_LIMIT_DELAY=5.0
|
| 29 |
+
|
| 30 |
+
# API Timeouts (seconds)
|
| 31 |
+
REQUEST_TIMEOUT=30
|
| 32 |
+
MAX_WORKERS=10 # max concurrent workers for verification
|
| 33 |
+
|
| 34 |
+
# Logging
|
| 35 |
+
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 36 |
+
LOG_FORMAT=json # json or text
|
| 37 |
+
LOG_FILE=logs/citescan.log
|
| 38 |
+
|
| 39 |
+
# CORS Settings (comma-separated origins)
|
| 40 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:8080
|
| 41 |
+
|
| 42 |
+
# Optional: API Keys (if needed in future)
|
| 43 |
+
# SEMANTIC_SCHOLAR_API_KEY=
|
| 44 |
+
# CROSSREF_API_KEY=
|
CiteScan
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Subproject commit 5386ca09348bfbdd78ef2b97feb106ebd6c575cd
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for CiteScan
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
gcc \
|
| 10 |
+
g++ \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Copy requirements first for better caching
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
|
| 16 |
+
# Install Python dependencies
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Create logs directory
|
| 23 |
+
RUN mkdir -p logs
|
| 24 |
+
|
| 25 |
+
# Expose ports
|
| 26 |
+
EXPOSE 7860 8000
|
| 27 |
+
|
| 28 |
+
# Environment variables
|
| 29 |
+
ENV PYTHONUNBUFFERED=1
|
| 30 |
+
ENV ENVIRONMENT=production
|
| 31 |
+
|
| 32 |
+
# Health check
|
| 33 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 34 |
+
CMD python -c "import requests; requests.get('http://localhost:8000/api/v1/health', timeout=5)"
|
| 35 |
+
|
| 36 |
+
# Default command (can be overridden)
|
| 37 |
+
CMD ["python", "main.py"]
|
README.md
CHANGED
|
@@ -1,49 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# CiteScan: Check References, Confirm Truth.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
|
|
|
|
|
|
|
| 10 |
|
| 11 |
## 🛡 Why CiteScan?
|
| 12 |
|
| 13 |
-
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
- **📋 Ground Truth Reference**: Provide the link if the citations are flagged to *issued entry*. You can click the **Open paper** or **DOI** button to access the real-world metadata, and Then cite the BibTex from the press website.
|
| 16 |
-
|
| 17 |

|
| 18 |
|
| 19 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
## References Validation
|
| 22 |
-
- **Multi-Source Verification**: Validates metadata against arXiv, CrossRef, DBLP, Semantic Scholar, OpenAlex, and Google Scholar
|
| 23 |
|
|
|
|
| 24 |
|
|
|
|
| 25 |
|
| 26 |

|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
- *Reason*: Different databases deal with a longer list of authors with different strategies, like truncation.
|
| 33 |
-
- *Action*: Verify if main authors match
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
## 🙏 Acknowledgments
|
| 49 |
|
|
@@ -53,30 +253,63 @@ CiteScan uses multiple data sources:
|
|
| 53 |
- Semantic Scholar API
|
| 54 |
- DBLP API
|
| 55 |
- OpenAlex API
|
|
|
|
| 56 |
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
```shell
|
| 60 |
|
| 61 |
-
#
|
| 62 |
-
git remote remove modelscope
|
| 63 |
-
git remote add modelscope "http://oauth2:ms-28735aa7-04b6-4b21-b4e0-cfb464f3587f@www.modelscope.cn/studios/aivolcano/CiteScan.git"
|
| 64 |
|
| 65 |
-
|
| 66 |
-
git remote add modelscope "http://oauth2:ms-28735aa7-04b6-4b21-b4e0-cfb464f3587f@www.modelscope.cn/studios/aivolcano/CiteScan.git"
|
| 67 |
|
| 68 |
-
#
|
| 69 |
-
git push modelscope main
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
|
|
|
| 73 |
|
|
|
|
| 74 |
|
| 75 |
-
#
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
#
|
| 79 |
-
|
| 80 |
-
# 创空间里点 「上线空间展示」 或 「立即发布」,等部署完成即可访问 Gradio 应用。
|
| 81 |
```
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CiteScan
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "4.44.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
# CiteScan: Check References, Confirm Truth.
|
| 13 |
|
| 14 |
+
**CiteScan** is an open-source and free tool designed to detect hallucinated references in academic writing. As AI coding assistants and writing tools become more prevalent, they sometimes generate plausible-sounding citations that do not actually exist. **CiteScan** addresses this issue by validating every bibliography entry against multiple authoritative academic databases—including arXiv, CrossRef, DBLP, Semantic Scholar, OpenAlex, and Google Scholar—to confirm their authenticity.
|
| 15 |
+
|
| 16 |
+
Going beyond simple verification, **CiteScan** uses rule-based algorithms to analyze whether the cited papers genuinely support the claims made in your text. Thanks to the free accessibility for academic databases across CS and AI areas, our system will **cost $0 for maintenance after development**.
|
| 17 |
+
|
| 18 |
+
## 🚀 Quick Start
|
| 19 |
+
|
| 20 |
+
### Option 1: Web Interface (Gradio)
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
# Install dependencies
|
| 24 |
+
pip install -r requirements.txt
|
| 25 |
+
|
| 26 |
+
# Run Gradio interface
|
| 27 |
+
python app.py
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Access at `http://localhost:7860`
|
| 31 |
+
|
| 32 |
+
### Option 2: API Service (FastAPI)
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
# Install dependencies
|
| 36 |
+
pip install -r requirements.txt
|
| 37 |
|
| 38 |
+
# Run API service
|
| 39 |
+
python main.py
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Access API at `http://localhost:8000`
|
| 43 |
+
API Documentation at `http://localhost:8000/docs`
|
| 44 |
+
|
| 45 |
+
### Option 3: Docker
|
| 46 |
|
| 47 |
+
```bash
|
| 48 |
+
# Run both services with Docker Compose
|
| 49 |
+
docker-compose up -d
|
| 50 |
+
|
| 51 |
+
# Gradio: http://localhost:7860
|
| 52 |
+
# API: http://localhost:8000
|
| 53 |
+
```
|
| 54 |
|
| 55 |
+
## 📚 Documentation
|
| 56 |
|
| 57 |
+
- **[API Documentation](API_DOCS.md)** - Complete API reference and examples
|
| 58 |
+
- **[Deployment Guide](DEPLOYMENT.md)** - Production deployment instructions
|
| 59 |
|
| 60 |
## 🛡 Why CiteScan?
|
| 61 |
|
| 62 |
+
- **🚫 NO Hallucinations**: Annotate citations that don't exist or have mismatched metadata across year, authors, and title.
|
| 63 |
+
|
| 64 |
+
- **📋 Ground Truth Reference**: Provide the link if the citations are flagged to *issued entry*. You can click the **Open paper** or **DOI** button to access the real-world metadata, and then cite the BibTeX from the press website.
|
| 65 |
|
|
|
|
|
|
|
| 66 |

|
| 67 |
|
| 68 |
+
- **🏠 Top-tier Research Organizations**: Cooperate with National University of Singapore (NUS) and Shanghai Jiao Tong University (SJTU).
|
| 69 |
+
|
| 70 |
+
- **🔌 RESTful API**: Production-ready API for integration with other tools and services.
|
| 71 |
+
|
| 72 |
+
## ✨ Features
|
| 73 |
+
|
| 74 |
+
### Web Interface (Gradio)
|
| 75 |
+
- User-friendly interface for manual verification
|
| 76 |
+
- Real-time progress tracking
|
| 77 |
+
- Interactive filtering by verification status
|
| 78 |
+
- Visual presentation of results
|
| 79 |
+
|
| 80 |
+
### API Service (FastAPI)
|
| 81 |
+
- RESTful API for programmatic access
|
| 82 |
+
- Automatic OpenAPI documentation
|
| 83 |
+
- JSON responses for easy integration
|
| 84 |
+
- Health checks and monitoring endpoints
|
| 85 |
+
- Structured logging
|
| 86 |
+
- Caching for improved performance
|
| 87 |
|
| 88 |
+
## 🔍 References Validation
|
|
|
|
| 89 |
|
| 90 |
+
- **Multi-Source Verification**: Validates metadata against arXiv, CrossRef, DBLP, Semantic Scholar, OpenAlex, and Google Scholar.
|
| 91 |
|
| 92 |
+
- **Covert citation from pre-print version to official version**: After clicking the blue button (`Open paper` or `DOI`), the official website will display. Click the `cite` button, you can copy the official BibTex.
|
| 93 |
|
| 94 |

|
| 95 |
|
| 96 |
+
### Verification Workflow
|
| 97 |
+
|
| 98 |
+
1. **Parse BibTeX**: Extract entries and metadata
|
| 99 |
+
2. **Priority-based Search**: Query databases in priority order
|
| 100 |
+
3. **Metadata Comparison**: Compare title, authors, year, venue
|
| 101 |
+
4. **Duplicate Detection**: Identify duplicate entries
|
| 102 |
+
5. **Result Generation**: Provide detailed verification report
|
| 103 |
+
|
| 104 |
+
## 📖 API Usage Examples
|
| 105 |
+
|
| 106 |
+
### Python
|
| 107 |
+
|
| 108 |
+
```python
|
| 109 |
+
import requests
|
| 110 |
+
|
| 111 |
+
url = "http://localhost:8000/api/v1/verify"
|
| 112 |
+
bibtex = """
|
| 113 |
+
@article{vaswani2017attention,
|
| 114 |
+
title={Attention is all you need},
|
| 115 |
+
author={Vaswani, Ashish and Shazeer, Noam},
|
| 116 |
+
year={2017}
|
| 117 |
+
}
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
response = requests.post(url, json={"bibtex_content": bibtex})
|
| 121 |
+
result = response.json()
|
| 122 |
+
|
| 123 |
+
print(f"Verified: {result['verified_count']}/{result['total_count']}")
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### cURL
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
curl -X POST "http://localhost:8000/api/v1/verify" \
|
| 130 |
+
-H "Content-Type: application/json" \
|
| 131 |
+
-d '{"bibtex_content": "@article{example,title={Test},year={2023}}"}'
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
See [API_DOCS.md](API_DOCS.md) for complete API documentation.
|
| 135 |
+
|
| 136 |
+
## ⚙️ Configuration
|
| 137 |
+
|
| 138 |
+
Create a `.env` file from the template:
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
cp .env.example .env
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Key configuration options:
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
# Server ports
|
| 148 |
+
API_PORT=8000
|
| 149 |
+
GRADIO_PORT=7860
|
| 150 |
+
|
| 151 |
+
# Performance
|
| 152 |
+
MAX_WORKERS=10
|
| 153 |
+
CACHE_ENABLED=true
|
| 154 |
+
CACHE_TTL=3600
|
| 155 |
+
|
| 156 |
+
# Logging
|
| 157 |
+
LOG_LEVEL=INFO
|
| 158 |
+
LOG_FORMAT=json
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete configuration guide.
|
| 162 |
+
|
| 163 |
+
## 🏗️ Architecture
|
| 164 |
+
|
| 165 |
+
```
|
| 166 |
+
CiteScan/
|
| 167 |
+
├── src/
|
| 168 |
+
│ ├── api/ # FastAPI routes and schemas
|
| 169 |
+
│ ├── services/ # Business logic layer
|
| 170 |
+
│ ├── core/ # Configuration, logging, cache
|
| 171 |
+
│ ├── fetchers/ # Database API clients
|
| 172 |
+
│ ├── analyzers/ # Metadata comparison
|
| 173 |
+
│ ├── parsers/ # BibTeX parsing
|
| 174 |
+
│ └── utils/ # Utilities
|
| 175 |
+
├── app.py # Gradio interface
|
| 176 |
+
├── main.py # FastAPI application
|
| 177 |
+
├── Dockerfile # Container configuration
|
| 178 |
+
└── docker-compose.yml # Multi-service setup
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
## 🔧 Development
|
| 182 |
+
|
| 183 |
+
### Setup Development Environment
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
# Create virtual environment
|
| 187 |
+
python -m venv venv
|
| 188 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 189 |
+
|
| 190 |
+
# Install dependencies
|
| 191 |
+
pip install -r requirements.txt
|
| 192 |
+
|
| 193 |
+
# Copy environment template
|
| 194 |
+
cp .env.example .env
|
| 195 |
+
|
| 196 |
+
# Run in development mode
|
| 197 |
+
ENVIRONMENT=development python main.py
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
### Project Structure
|
| 201 |
+
|
| 202 |
+
- **Services Layer**: Reusable business logic
|
| 203 |
+
- **API Layer**: RESTful endpoints with FastAPI
|
| 204 |
+
- **UI Layer**: Gradio interface
|
| 205 |
+
- **Core**: Configuration, logging, caching
|
| 206 |
+
- **Fetchers**: Database API integrations
|
| 207 |
+
|
| 208 |
+
## 📊 Monitoring
|
| 209 |
+
|
| 210 |
+
### Health Check
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
curl http://localhost:8000/api/v1/health
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
### Statistics
|
| 217 |
|
| 218 |
+
```bash
|
| 219 |
+
curl http://localhost:8000/api/v1/stats
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
### Logs
|
| 223 |
+
|
| 224 |
+
Logs are stored in `logs/citescan.log` in JSON format:
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
tail -f logs/citescan.log | jq '.'
|
| 228 |
+
```
|
| 229 |
|
| 230 |
+
## ⚠️ Case Study for False Positives
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
1. **Authors Mismatch**:
|
| 233 |
+
- *Reason*: Different databases deal with a longer list of authors with different strategies, like truncation.
|
| 234 |
+
- *Action*: Verify if main authors match
|
| 235 |
|
| 236 |
+
2. **Venues Mismatch**:
|
| 237 |
+
- *Reason*: Abbreviations vs. full names, such as "ICLR" vs. "International Conference on Learning Representations"
|
| 238 |
+
- *Action*: Both are correct.
|
| 239 |
|
| 240 |
+
3. **Year GAP (±1 Year)**:
|
| 241 |
+
- *Reason*: Delay between preprint (arXiv) and final version publication
|
| 242 |
+
- *Action*: Verify which version you intend to cite. We recommend citing the version from the official press website. Lower pre-print version bib will make your submission more convincing.
|
| 243 |
|
| 244 |
+
4. **Non-academic Sources**:
|
| 245 |
+
- *Reason*: Blogs and APIs are not indexed in academic databases.
|
| 246 |
+
- *Action*: Verify URL, year, and title manually.
|
| 247 |
|
| 248 |
## 🙏 Acknowledgments
|
| 249 |
|
|
|
|
| 253 |
- Semantic Scholar API
|
| 254 |
- DBLP API
|
| 255 |
- OpenAlex API
|
| 256 |
+
- Google Scholar (web scraping)
|
| 257 |
|
| 258 |
+
## 📝 License
|
| 259 |
|
| 260 |
+
[Add your license here]
|
|
|
|
| 261 |
|
| 262 |
+
## 🤝 Contributing
|
|
|
|
|
|
|
| 263 |
|
| 264 |
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
|
|
| 265 |
|
| 266 |
+
## 📧 Contact
|
|
|
|
| 267 |
|
| 268 |
+
For questions and support:
|
| 269 |
+
- Email: e1143641@u.nus.edu
|
| 270 |
+
- GitHub Issues: [Repository URL]
|
| 271 |
|
| 272 |
+
---
|
| 273 |
|
| 274 |
+
## 🚀 ModelScope Deployment
|
| 275 |
+
|
| 276 |
+
To deploy on ModelScope 创空间:
|
| 277 |
+
|
| 278 |
+
```bash
|
| 279 |
+
# Add ModelScope remote
|
| 280 |
+
git remote add modelscope "http://oauth2:YOUR_TOKEN@www.modelscope.cn/studios/YOUR_USERNAME/CiteScan.git"
|
| 281 |
+
|
| 282 |
+
# Push to ModelScope
|
| 283 |
+
git push modelscope main
|
| 284 |
|
| 285 |
+
# Or force push if needed
|
| 286 |
+
git push modelscope main --force
|
|
|
|
| 287 |
```
|
| 288 |
|
| 289 |
+
After pushing, visit your ModelScope studio and click "上线空间展示" or "立即发布" to deploy the Gradio application.
|
| 290 |
+
|
| 291 |
+
---
|
| 292 |
+
|
| 293 |
+
## 🚀 Hugging Face Spaces 部署
|
| 294 |
+
|
| 295 |
+
将代码推送到 [Hugging Face Spaces](https://huggingface.co/spaces/yancan/CiteScan/):
|
| 296 |
+
|
| 297 |
+
1. **安装 Hugging Face CLI 并登录**(如未安装):
|
| 298 |
+
```bash
|
| 299 |
+
pip install huggingface_hub
|
| 300 |
+
huggingface-cli login
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
2. **添加 Hugging Face 远程仓库**:
|
| 304 |
+
```bash
|
| 305 |
+
git remote add hf https://huggingface.co/spaces/yancan/CiteScan
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
3. **推送到 Spaces**(HF 不允许普通 git 推送二进制文件,需用无图片分支 `hf-main`):
|
| 309 |
+
- **重要**:HF 上显示的是 **已提交到 main 的代码**。若本地有未提交的修改(如 `main.py`、`src/` 等),需先提交到 `main`,再更新并推送 `hf-main`。
|
| 310 |
+
- 一键脚本:`./scripts/push_to_hf.sh`(会提示先提交未提交的修改,再重建 `hf-main` 并推送)。
|
| 311 |
+
- 或手动:先 `git add -A && git commit -m "说明"`,再运行脚本或按脚本内步骤重建 `hf-main` 并 `git push hf hf-main:main --force`。
|
| 312 |
+
|
| 313 |
+
4. 推送完成后,在 [Space 页面](https://huggingface.co/spaces/yancan/CiteScan) 等待构建结束即可访问 Gradio 应用。
|
| 314 |
+
|
| 315 |
+
**注意**:README 顶部的 YAML 配置(`title`、`sdk`、`app_file` 等)为 Spaces 必需,请勿删除。
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# FastAPI service
|
| 5 |
+
api:
|
| 6 |
+
build: .
|
| 7 |
+
container_name: citescan-api
|
| 8 |
+
command: python main.py
|
| 9 |
+
ports:
|
| 10 |
+
- "8000:8000"
|
| 11 |
+
environment:
|
| 12 |
+
- ENVIRONMENT=development
|
| 13 |
+
- API_HOST=0.0.0.0
|
| 14 |
+
- API_PORT=8000
|
| 15 |
+
- LOG_LEVEL=INFO
|
| 16 |
+
- CACHE_ENABLED=true
|
| 17 |
+
volumes:
|
| 18 |
+
- ./logs:/app/logs
|
| 19 |
+
- ./.env:/app/.env
|
| 20 |
+
restart: unless-stopped
|
| 21 |
+
healthcheck:
|
| 22 |
+
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/api/v1/health')"]
|
| 23 |
+
interval: 30s
|
| 24 |
+
timeout: 10s
|
| 25 |
+
retries: 3
|
| 26 |
+
start_period: 10s
|
| 27 |
+
|
| 28 |
+
# Gradio service
|
| 29 |
+
gradio:
|
| 30 |
+
build: .
|
| 31 |
+
container_name: citescan-gradio
|
| 32 |
+
command: python app.py
|
| 33 |
+
ports:
|
| 34 |
+
- "7860:7860"
|
| 35 |
+
environment:
|
| 36 |
+
- ENVIRONMENT=development
|
| 37 |
+
- GRADIO_HOST=0.0.0.0
|
| 38 |
+
- GRADIO_PORT=7860
|
| 39 |
+
- LOG_LEVEL=INFO
|
| 40 |
+
- CACHE_ENABLED=true
|
| 41 |
+
volumes:
|
| 42 |
+
- ./logs:/app/logs
|
| 43 |
+
- ./.env:/app/.env
|
| 44 |
+
restart: unless-stopped
|
| 45 |
+
depends_on:
|
| 46 |
+
- api
|
| 47 |
+
|
| 48 |
+
networks:
|
| 49 |
+
default:
|
| 50 |
+
name: citescan-network
|
main.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry point."""
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
from fastapi import FastAPI, Request, status
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
from fastapi.exceptions import RequestValidationError
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from src.core.config import settings
|
| 10 |
+
from src.core.logging import setup_logging, get_logger
|
| 11 |
+
from src.api.routes import verification, health
|
| 12 |
+
|
| 13 |
+
# Setup logging
|
| 14 |
+
setup_logging()
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
"""Application lifespan manager."""
|
| 21 |
+
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
| 22 |
+
logger.info(f"Environment: {settings.environment}")
|
| 23 |
+
logger.info(f"API running on {settings.api_host}:{settings.api_port}")
|
| 24 |
+
yield
|
| 25 |
+
logger.info("Shutting down application")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Create FastAPI app
|
| 29 |
+
app = FastAPI(
|
| 30 |
+
title=settings.app_name,
|
| 31 |
+
version=settings.app_version,
|
| 32 |
+
description="API for verifying BibTeX references against academic databases",
|
| 33 |
+
lifespan=lifespan,
|
| 34 |
+
docs_url="/docs",
|
| 35 |
+
redoc_url="/redoc",
|
| 36 |
+
openapi_url="/openapi.json",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# CORS middleware
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware,
|
| 42 |
+
allow_origins=settings.cors_origins_list,
|
| 43 |
+
allow_credentials=True,
|
| 44 |
+
allow_methods=["*"],
|
| 45 |
+
allow_headers=["*"],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# Request timing middleware
|
| 50 |
+
@app.middleware("http")
|
| 51 |
+
async def add_process_time_header(request: Request, call_next):
|
| 52 |
+
"""Add processing time to response headers."""
|
| 53 |
+
start_time = time.time()
|
| 54 |
+
response = await call_next(request)
|
| 55 |
+
process_time = time.time() - start_time
|
| 56 |
+
response.headers["X-Process-Time"] = str(process_time)
|
| 57 |
+
return response
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Exception handlers
|
| 61 |
+
@app.exception_handler(RequestValidationError)
|
| 62 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 63 |
+
"""Handle validation errors."""
|
| 64 |
+
logger.warning(f"Validation error: {exc}")
|
| 65 |
+
return JSONResponse(
|
| 66 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 67 |
+
content={
|
| 68 |
+
"error": "ValidationError",
|
| 69 |
+
"message": "Invalid request data",
|
| 70 |
+
"details": exc.errors(),
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@app.exception_handler(Exception)
|
| 76 |
+
async def general_exception_handler(request: Request, exc: Exception):
|
| 77 |
+
"""Handle general exceptions."""
|
| 78 |
+
logger.exception(f"Unhandled exception: {exc}")
|
| 79 |
+
return JSONResponse(
|
| 80 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 81 |
+
content={
|
| 82 |
+
"error": "InternalServerError",
|
| 83 |
+
"message": "An unexpected error occurred",
|
| 84 |
+
},
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Include routers
|
| 89 |
+
app.include_router(verification.router)
|
| 90 |
+
app.include_router(health.router)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# Root endpoint
|
| 94 |
+
@app.get("/", tags=["root"])
|
| 95 |
+
async def root():
|
| 96 |
+
"""Root endpoint."""
|
| 97 |
+
return {
|
| 98 |
+
"name": settings.app_name,
|
| 99 |
+
"version": settings.app_version,
|
| 100 |
+
"environment": settings.environment,
|
| 101 |
+
"docs": "/docs",
|
| 102 |
+
"health": "/api/v1/health",
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
import uvicorn
|
| 108 |
+
|
| 109 |
+
uvicorn.run(
|
| 110 |
+
"main:app",
|
| 111 |
+
host=settings.api_host,
|
| 112 |
+
port=settings.api_port,
|
| 113 |
+
reload=settings.is_development,
|
| 114 |
+
log_level=settings.log_level.lower(),
|
| 115 |
+
)
|
scripts/push_to_hf.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# 把当前最新代码推到 Hugging Face Space(先提交到 main,再更新 hf-main 并推送)
|
| 3 |
+
set -e
|
| 4 |
+
cd "$(dirname "$0")/.."
|
| 5 |
+
|
| 6 |
+
echo "=== 1. 检查未提交的修改 ==="
|
| 7 |
+
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git status --porcelain)" ]; then
|
| 8 |
+
echo "当前有未提交的修改。请先提交到 main:"
|
| 9 |
+
echo " git add -A"
|
| 10 |
+
echo " git commit -m '你的提交说明'"
|
| 11 |
+
echo " git push origin main # 可选:同步到 GitHub"
|
| 12 |
+
echo ""
|
| 13 |
+
read -p "是否现在执行 git add -A && git commit?(y/N) " -n 1 -r
|
| 14 |
+
echo
|
| 15 |
+
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
| 16 |
+
read -p "请输入 commit message: " msg
|
| 17 |
+
git add -A
|
| 18 |
+
git commit -m "${msg:-Update for HF Spaces}"
|
| 19 |
+
else
|
| 20 |
+
echo "已取消。请先提交后再运行此脚本。"
|
| 21 |
+
exit 1
|
| 22 |
+
fi
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
echo ""
|
| 26 |
+
echo "=== 2. 用当前 main 重建 hf-main(并移除二进制文件)==="
|
| 27 |
+
# 备份当前 hf-main 的 ref(可选)
|
| 28 |
+
git branch -D hf-main 2>/dev/null || true
|
| 29 |
+
git checkout -b hf-main main
|
| 30 |
+
|
| 31 |
+
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --index-filter \
|
| 32 |
+
'git rm -q --cached --ignore-unmatch \
|
| 33 |
+
assets/logo_nus.png \
|
| 34 |
+
assets/logo_sjtu.png \
|
| 35 |
+
assets/screenshot_performance.png \
|
| 36 |
+
assets/screenshot_performance_zh.png \
|
| 37 |
+
assets/screenshot_semantic_scholar.png' -- hf-main
|
| 38 |
+
|
| 39 |
+
git checkout main
|
| 40 |
+
echo ""
|
| 41 |
+
echo "=== 3. 推送到 Hugging Face ==="
|
| 42 |
+
git push hf hf-main:main --force
|
| 43 |
+
echo ""
|
| 44 |
+
echo "完成。请到 https://huggingface.co/spaces/yancan/CiteScan 查看构建状态。"
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API package."""
|
src/api/dependencies.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API dependencies and dependency injection."""
|
| 2 |
+
from typing import Annotated
|
| 3 |
+
from fastapi import Depends
|
| 4 |
+
|
| 5 |
+
from src.services import VerificationService
|
| 6 |
+
from src.core.cache import cache_manager
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_verification_service() -> VerificationService:
|
| 10 |
+
"""Get verification service instance.
|
| 11 |
+
|
| 12 |
+
Returns:
|
| 13 |
+
VerificationService instance
|
| 14 |
+
"""
|
| 15 |
+
return VerificationService()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_cache_manager():
|
| 19 |
+
"""Get cache manager instance.
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
CacheManager instance
|
| 23 |
+
"""
|
| 24 |
+
return cache_manager
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Type aliases for dependency injection
|
| 28 |
+
VerificationServiceDep = Annotated[VerificationService, Depends(get_verification_service)]
|
| 29 |
+
CacheManagerDep = Annotated[type(cache_manager), Depends(get_cache_manager)]
|
src/api/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API routes package."""
|
src/api/routes/health.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health check and system routes."""
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
|
| 4 |
+
from src.api.schemas import HealthResponse, StatsResponse
|
| 5 |
+
from src.api.dependencies import CacheManagerDep
|
| 6 |
+
from src.core.config import settings
|
| 7 |
+
from src.core.logging import get_logger
|
| 8 |
+
|
| 9 |
+
logger = get_logger(__name__)
|
| 10 |
+
router = APIRouter(prefix="/api/v1", tags=["system"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get(
|
| 14 |
+
"/health",
|
| 15 |
+
response_model=HealthResponse,
|
| 16 |
+
summary="Health check",
|
| 17 |
+
description="Check if the service is running and healthy",
|
| 18 |
+
)
|
| 19 |
+
async def health_check() -> HealthResponse:
|
| 20 |
+
"""Health check endpoint.
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Health status
|
| 24 |
+
"""
|
| 25 |
+
return HealthResponse(
|
| 26 |
+
status="healthy",
|
| 27 |
+
version=settings.app_version,
|
| 28 |
+
environment=settings.environment,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get(
|
| 33 |
+
"/stats",
|
| 34 |
+
response_model=StatsResponse,
|
| 35 |
+
summary="Get statistics",
|
| 36 |
+
description="Get system statistics including cache information",
|
| 37 |
+
)
|
| 38 |
+
async def get_stats(cache: CacheManagerDep) -> StatsResponse:
|
| 39 |
+
"""Get system statistics.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
cache: Cache manager instance
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
System statistics
|
| 46 |
+
"""
|
| 47 |
+
cache_stats = cache.get_stats()
|
| 48 |
+
|
| 49 |
+
return StatsResponse(
|
| 50 |
+
cache_enabled=cache_stats["enabled"],
|
| 51 |
+
cache_size=cache_stats["size"],
|
| 52 |
+
cache_max_size=cache_stats["max_size"],
|
| 53 |
+
cache_ttl=cache_stats["ttl"],
|
| 54 |
+
)
|
src/api/routes/verification.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verification API routes."""
|
| 2 |
+
from fastapi import APIRouter, HTTPException, status
|
| 3 |
+
from fastapi.responses import JSONResponse
|
| 4 |
+
|
| 5 |
+
from src.api.schemas import (
|
| 6 |
+
BibTeXVerifyRequest,
|
| 7 |
+
BibTeXVerifyResponse,
|
| 8 |
+
EntryComparisonResponse,
|
| 9 |
+
DuplicateGroupResponse,
|
| 10 |
+
ErrorResponse,
|
| 11 |
+
)
|
| 12 |
+
from src.api.dependencies import VerificationServiceDep
|
| 13 |
+
from src.core.logging import get_logger
|
| 14 |
+
from src.core.exceptions import ParserException, FetcherException
|
| 15 |
+
|
| 16 |
+
logger = get_logger(__name__)
|
| 17 |
+
router = APIRouter(prefix="/api/v1", tags=["verification"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get_entry_status(comparison) -> str:
|
| 21 |
+
"""Determine entry status from comparison result."""
|
| 22 |
+
if comparison and comparison.is_match:
|
| 23 |
+
return "verified"
|
| 24 |
+
elif comparison and comparison.has_issues:
|
| 25 |
+
return "warning"
|
| 26 |
+
return "error"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post(
|
| 30 |
+
"/verify",
|
| 31 |
+
response_model=BibTeXVerifyResponse,
|
| 32 |
+
status_code=status.HTTP_200_OK,
|
| 33 |
+
summary="Verify BibTeX entries",
|
| 34 |
+
description="Verify BibTeX entries against multiple academic databases",
|
| 35 |
+
responses={
|
| 36 |
+
200: {"description": "Verification completed successfully"},
|
| 37 |
+
400: {"model": ErrorResponse, "description": "Invalid BibTeX content"},
|
| 38 |
+
500: {"model": ErrorResponse, "description": "Internal server error"},
|
| 39 |
+
},
|
| 40 |
+
)
|
| 41 |
+
async def verify_bibtex(
|
| 42 |
+
request: BibTeXVerifyRequest,
|
| 43 |
+
service: VerificationServiceDep,
|
| 44 |
+
) -> BibTeXVerifyResponse:
|
| 45 |
+
"""Verify BibTeX entries.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
request: BibTeX verification request
|
| 49 |
+
service: Verification service instance
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Verification results
|
| 53 |
+
|
| 54 |
+
Raises:
|
| 55 |
+
HTTPException: If verification fails
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
logger.info("Received BibTeX verification request")
|
| 59 |
+
|
| 60 |
+
# Verify BibTeX
|
| 61 |
+
result = service.verify_bibtex_string(request.bibtex_content)
|
| 62 |
+
|
| 63 |
+
# Convert entry reports to response format
|
| 64 |
+
entries = []
|
| 65 |
+
for entry_report in result.entry_reports:
|
| 66 |
+
entry = entry_report.entry
|
| 67 |
+
comparison = entry_report.comparison
|
| 68 |
+
|
| 69 |
+
# Format original BibTeX
|
| 70 |
+
bibtex_str = f"@{entry.entry_type}{{{entry.key},\n"
|
| 71 |
+
for field, value in (entry.raw_entry or {}).items():
|
| 72 |
+
if field in ("ID", "ENTRYTYPE"):
|
| 73 |
+
continue
|
| 74 |
+
if value is not None and str(value).strip():
|
| 75 |
+
bibtex_str += f" {field}={{{value}}},\n"
|
| 76 |
+
bibtex_str = bibtex_str.rstrip(",\n") + "\n}"
|
| 77 |
+
|
| 78 |
+
entry_response = EntryComparisonResponse(
|
| 79 |
+
key=entry.key,
|
| 80 |
+
status=_get_entry_status(comparison),
|
| 81 |
+
is_match=comparison.is_match if comparison else False,
|
| 82 |
+
has_issues=comparison.has_issues if comparison else False,
|
| 83 |
+
source=getattr(comparison, "source", None) if comparison else None,
|
| 84 |
+
confidence=getattr(comparison, "confidence", 0.0) if comparison else 0.0,
|
| 85 |
+
title_match=comparison.title_match if comparison else False,
|
| 86 |
+
author_match=comparison.author_match if comparison else False,
|
| 87 |
+
year_match=comparison.year_match if comparison else False,
|
| 88 |
+
venue_match=getattr(comparison, "venue_match", None) if comparison else None,
|
| 89 |
+
fetched_title=getattr(comparison, "fetched_title", None) if comparison else None,
|
| 90 |
+
fetched_authors=getattr(comparison, "fetched_authors", None) if comparison else None,
|
| 91 |
+
fetched_year=getattr(comparison, "fetched_year", None) if comparison else None,
|
| 92 |
+
fetched_doi=getattr(comparison, "fetched_doi", None) if comparison else None,
|
| 93 |
+
fetched_url=getattr(comparison, "fetched_url", None) if comparison else None,
|
| 94 |
+
original_bibtex=bibtex_str,
|
| 95 |
+
)
|
| 96 |
+
entries.append(entry_response)
|
| 97 |
+
|
| 98 |
+
# Convert duplicate groups
|
| 99 |
+
duplicate_groups = [
|
| 100 |
+
DuplicateGroupResponse(
|
| 101 |
+
entry_keys=group.entry_keys,
|
| 102 |
+
reason=group.reason,
|
| 103 |
+
)
|
| 104 |
+
for group in result.duplicate_groups
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
response = BibTeXVerifyResponse(
|
| 108 |
+
success=True,
|
| 109 |
+
message="Verification completed successfully",
|
| 110 |
+
total_count=result.total_count,
|
| 111 |
+
verified_count=result.verified_count,
|
| 112 |
+
warning_count=result.warning_count,
|
| 113 |
+
error_count=result.error_count,
|
| 114 |
+
success_rate=result.success_rate,
|
| 115 |
+
entries=entries,
|
| 116 |
+
duplicate_groups=duplicate_groups,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
logger.info(
|
| 120 |
+
f"Verification completed: {result.verified_count}/{result.total_count} verified"
|
| 121 |
+
)
|
| 122 |
+
return response
|
| 123 |
+
|
| 124 |
+
except ParserException as e:
|
| 125 |
+
logger.error(f"Parser error: {e}")
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 128 |
+
detail={"error": "ParserError", "message": str(e)},
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
except FetcherException as e:
|
| 132 |
+
logger.error(f"Fetcher error: {e}")
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 135 |
+
detail={"error": "FetcherError", "message": str(e)},
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.exception(f"Unexpected error during verification: {e}")
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 142 |
+
detail={"error": "InternalError", "message": "An unexpected error occurred"},
|
| 143 |
+
)
|
src/api/schemas/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for API requests and responses."""
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class BibTeXVerifyRequest(BaseModel):
|
| 8 |
+
"""Request model for BibTeX verification."""
|
| 9 |
+
|
| 10 |
+
bibtex_content: str = Field(
|
| 11 |
+
...,
|
| 12 |
+
description="BibTeX content to verify",
|
| 13 |
+
min_length=1,
|
| 14 |
+
examples=[
|
| 15 |
+
"""@article{example2023,
|
| 16 |
+
title={Example Paper Title},
|
| 17 |
+
author={Smith, John and Doe, Jane},
|
| 18 |
+
journal={Example Journal},
|
| 19 |
+
year={2023}
|
| 20 |
+
}"""
|
| 21 |
+
],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class EntryComparisonResponse(BaseModel):
|
| 26 |
+
"""Response model for a single entry comparison."""
|
| 27 |
+
|
| 28 |
+
key: str = Field(..., description="BibTeX entry key")
|
| 29 |
+
status: str = Field(..., description="Verification status: verified, warning, or error")
|
| 30 |
+
is_match: bool = Field(..., description="Whether entry matches a database record")
|
| 31 |
+
has_issues: bool = Field(..., description="Whether entry has metadata issues")
|
| 32 |
+
source: Optional[str] = Field(None, description="Data source that verified the entry")
|
| 33 |
+
confidence: float = Field(..., description="Confidence score (0-1)")
|
| 34 |
+
title_match: bool = Field(..., description="Whether title matches")
|
| 35 |
+
author_match: bool = Field(..., description="Whether authors match")
|
| 36 |
+
year_match: bool = Field(..., description="Whether year matches")
|
| 37 |
+
venue_match: Optional[bool] = Field(None, description="Whether venue matches")
|
| 38 |
+
fetched_title: Optional[str] = Field(None, description="Title from database")
|
| 39 |
+
fetched_authors: Optional[list[str]] = Field(None, description="Authors from database")
|
| 40 |
+
fetched_year: Optional[str] = Field(None, description="Year from database")
|
| 41 |
+
fetched_doi: Optional[str] = Field(None, description="DOI from database")
|
| 42 |
+
fetched_url: Optional[str] = Field(None, description="URL from database")
|
| 43 |
+
original_bibtex: str = Field(..., description="Original BibTeX entry")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class DuplicateGroupResponse(BaseModel):
|
| 47 |
+
"""Response model for duplicate entry groups."""
|
| 48 |
+
|
| 49 |
+
entry_keys: list[str] = Field(..., description="Keys of duplicate entries")
|
| 50 |
+
reason: str = Field(..., description="Reason for duplicate detection")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class BibTeXVerifyResponse(BaseModel):
|
| 54 |
+
"""Response model for BibTeX verification."""
|
| 55 |
+
|
| 56 |
+
success: bool = Field(..., description="Whether verification completed successfully")
|
| 57 |
+
message: str = Field(..., description="Status message")
|
| 58 |
+
total_count: int = Field(..., description="Total number of entries")
|
| 59 |
+
verified_count: int = Field(..., description="Number of verified entries")
|
| 60 |
+
warning_count: int = Field(..., description="Number of entries with warnings")
|
| 61 |
+
error_count: int = Field(..., description="Number of entries with errors")
|
| 62 |
+
success_rate: float = Field(..., description="Success rate percentage")
|
| 63 |
+
entries: list[EntryComparisonResponse] = Field(..., description="Verification results for each entry")
|
| 64 |
+
duplicate_groups: list[DuplicateGroupResponse] = Field(
|
| 65 |
+
default_factory=list, description="Groups of duplicate entries"
|
| 66 |
+
)
|
| 67 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Verification timestamp")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class HealthResponse(BaseModel):
|
| 71 |
+
"""Response model for health check."""
|
| 72 |
+
|
| 73 |
+
status: str = Field(..., description="Service status")
|
| 74 |
+
version: str = Field(..., description="Application version")
|
| 75 |
+
environment: str = Field(..., description="Runtime environment")
|
| 76 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Current timestamp")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class StatsResponse(BaseModel):
|
| 80 |
+
"""Response model for statistics."""
|
| 81 |
+
|
| 82 |
+
cache_enabled: bool = Field(..., description="Whether cache is enabled")
|
| 83 |
+
cache_size: int = Field(..., description="Current cache size")
|
| 84 |
+
cache_max_size: int = Field(..., description="Maximum cache size")
|
| 85 |
+
cache_ttl: int = Field(..., description="Cache TTL in seconds")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ErrorResponse(BaseModel):
|
| 89 |
+
"""Response model for errors."""
|
| 90 |
+
|
| 91 |
+
error: str = Field(..., description="Error type")
|
| 92 |
+
message: str = Field(..., description="Error message")
|
| 93 |
+
details: Optional[dict] = Field(None, description="Additional error details")
|
| 94 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp")
|
src/core/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core configuration and utilities."""
|
| 2 |
+
from .config import settings
|
| 3 |
+
from .logging import setup_logging, get_logger
|
| 4 |
+
from .cache import cache_manager
|
| 5 |
+
from .exceptions import (
|
| 6 |
+
CiteScanException,
|
| 7 |
+
FetcherException,
|
| 8 |
+
ParserException,
|
| 9 |
+
ValidationException,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"settings",
|
| 14 |
+
"setup_logging",
|
| 15 |
+
"get_logger",
|
| 16 |
+
"cache_manager",
|
| 17 |
+
"CiteScanException",
|
| 18 |
+
"FetcherException",
|
| 19 |
+
"ParserException",
|
| 20 |
+
"ValidationException",
|
| 21 |
+
]
|
src/core/cache.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cache management for CiteScan."""
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Callable, Optional
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from cachetools import TTLCache
|
| 7 |
+
import threading
|
| 8 |
+
|
| 9 |
+
from .config import settings
|
| 10 |
+
from .logging import get_logger
|
| 11 |
+
|
| 12 |
+
logger = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CacheManager:
|
| 16 |
+
"""Thread-safe cache manager using TTLCache."""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
"""Initialize cache manager."""
|
| 20 |
+
self._cache: Optional[TTLCache] = None
|
| 21 |
+
self._lock = threading.Lock()
|
| 22 |
+
self._initialize_cache()
|
| 23 |
+
|
| 24 |
+
def _initialize_cache(self) -> None:
|
| 25 |
+
"""Initialize the cache based on settings."""
|
| 26 |
+
if settings.cache_enabled:
|
| 27 |
+
self._cache = TTLCache(
|
| 28 |
+
maxsize=settings.cache_max_size,
|
| 29 |
+
ttl=settings.cache_ttl
|
| 30 |
+
)
|
| 31 |
+
logger.info(
|
| 32 |
+
f"Cache initialized: max_size={settings.cache_max_size}, "
|
| 33 |
+
f"ttl={settings.cache_ttl}s"
|
| 34 |
+
)
|
| 35 |
+
else:
|
| 36 |
+
logger.info("Cache disabled")
|
| 37 |
+
|
| 38 |
+
def _generate_key(self, *args, **kwargs) -> str:
|
| 39 |
+
"""Generate a cache key from arguments.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
*args: Positional arguments
|
| 43 |
+
**kwargs: Keyword arguments
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Cache key as hex string
|
| 47 |
+
"""
|
| 48 |
+
# Create a stable string representation
|
| 49 |
+
key_data = {
|
| 50 |
+
"args": args,
|
| 51 |
+
"kwargs": sorted(kwargs.items())
|
| 52 |
+
}
|
| 53 |
+
key_str = json.dumps(key_data, sort_keys=True, default=str)
|
| 54 |
+
return hashlib.md5(key_str.encode()).hexdigest()
|
| 55 |
+
|
| 56 |
+
def get(self, key: str) -> Optional[Any]:
|
| 57 |
+
"""Get value from cache.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
key: Cache key
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Cached value or None if not found
|
| 64 |
+
"""
|
| 65 |
+
if not settings.cache_enabled or self._cache is None:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
with self._lock:
|
| 69 |
+
value = self._cache.get(key)
|
| 70 |
+
if value is not None:
|
| 71 |
+
logger.debug(f"Cache hit: {key}")
|
| 72 |
+
return value
|
| 73 |
+
|
| 74 |
+
def set(self, key: str, value: Any) -> None:
|
| 75 |
+
"""Set value in cache.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
key: Cache key
|
| 79 |
+
value: Value to cache
|
| 80 |
+
"""
|
| 81 |
+
if not settings.cache_enabled or self._cache is None:
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
with self._lock:
|
| 85 |
+
self._cache[key] = value
|
| 86 |
+
logger.debug(f"Cache set: {key}")
|
| 87 |
+
|
| 88 |
+
def delete(self, key: str) -> None:
|
| 89 |
+
"""Delete value from cache.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
key: Cache key
|
| 93 |
+
"""
|
| 94 |
+
if not settings.cache_enabled or self._cache is None:
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
with self._lock:
|
| 98 |
+
if key in self._cache:
|
| 99 |
+
del self._cache[key]
|
| 100 |
+
logger.debug(f"Cache deleted: {key}")
|
| 101 |
+
|
| 102 |
+
def clear(self) -> None:
|
| 103 |
+
"""Clear all cache entries."""
|
| 104 |
+
if not settings.cache_enabled or self._cache is None:
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
with self._lock:
|
| 108 |
+
self._cache.clear()
|
| 109 |
+
logger.info("Cache cleared")
|
| 110 |
+
|
| 111 |
+
def get_stats(self) -> dict[str, Any]:
|
| 112 |
+
"""Get cache statistics.
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Dictionary with cache stats
|
| 116 |
+
"""
|
| 117 |
+
if not settings.cache_enabled or self._cache is None:
|
| 118 |
+
return {
|
| 119 |
+
"enabled": False,
|
| 120 |
+
"size": 0,
|
| 121 |
+
"max_size": 0,
|
| 122 |
+
"ttl": 0
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
with self._lock:
|
| 126 |
+
return {
|
| 127 |
+
"enabled": True,
|
| 128 |
+
"size": len(self._cache),
|
| 129 |
+
"max_size": self._cache.maxsize,
|
| 130 |
+
"ttl": self._cache.ttl
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
def cached(self, key_prefix: str = "") -> Callable:
|
| 134 |
+
"""Decorator to cache function results.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
key_prefix: Optional prefix for cache key
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Decorator function
|
| 141 |
+
"""
|
| 142 |
+
def decorator(func: Callable) -> Callable:
|
| 143 |
+
@wraps(func)
|
| 144 |
+
def wrapper(*args, **kwargs):
|
| 145 |
+
# Generate cache key
|
| 146 |
+
cache_key = f"{key_prefix}:{func.__name__}:{self._generate_key(*args, **kwargs)}"
|
| 147 |
+
|
| 148 |
+
# Try to get from cache
|
| 149 |
+
cached_value = self.get(cache_key)
|
| 150 |
+
if cached_value is not None:
|
| 151 |
+
return cached_value
|
| 152 |
+
|
| 153 |
+
# Call function and cache result
|
| 154 |
+
result = func(*args, **kwargs)
|
| 155 |
+
self.set(cache_key, result)
|
| 156 |
+
return result
|
| 157 |
+
|
| 158 |
+
return wrapper
|
| 159 |
+
return decorator
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# Global cache manager instance
|
| 163 |
+
cache_manager = CacheManager()
|
src/core/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration using Pydantic Settings."""
|
| 2 |
+
from typing import Literal
|
| 3 |
+
from pydantic import Field
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
"""Application settings loaded from environment variables."""
|
| 9 |
+
|
| 10 |
+
model_config = SettingsConfigDict(
|
| 11 |
+
env_file=".env",
|
| 12 |
+
env_file_encoding="utf-8",
|
| 13 |
+
case_sensitive=False,
|
| 14 |
+
extra="ignore"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# Application Settings
|
| 18 |
+
app_name: str = Field(default="CiteScan", description="Application name")
|
| 19 |
+
app_version: str = Field(default="1.0.0", description="Application version")
|
| 20 |
+
environment: Literal["development", "staging", "production"] = Field(
|
| 21 |
+
default="development",
|
| 22 |
+
description="Runtime environment"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Server Configuration
|
| 26 |
+
api_host: str = Field(default="0.0.0.0", description="API server host")
|
| 27 |
+
api_port: int = Field(default=8000, description="API server port")
|
| 28 |
+
gradio_host: str = Field(default="0.0.0.0", description="Gradio server host")
|
| 29 |
+
gradio_port: int = Field(default=7860, description="Gradio server port")
|
| 30 |
+
|
| 31 |
+
# API Rate Limiting
|
| 32 |
+
rate_limit_enabled: bool = Field(default=True, description="Enable rate limiting")
|
| 33 |
+
rate_limit_requests: int = Field(default=100, description="Max requests per period")
|
| 34 |
+
rate_limit_period: int = Field(default=60, description="Rate limit period in seconds")
|
| 35 |
+
|
| 36 |
+
# Cache Configuration
|
| 37 |
+
cache_enabled: bool = Field(default=True, description="Enable caching")
|
| 38 |
+
cache_ttl: int = Field(default=3600, description="Cache TTL in seconds")
|
| 39 |
+
cache_max_size: int = Field(default=1000, description="Max cached items")
|
| 40 |
+
|
| 41 |
+
# Fetcher Configuration
|
| 42 |
+
arxiv_rate_limit_delay: float = Field(default=3.0, description="arXiv rate limit delay")
|
| 43 |
+
crossref_rate_limit_delay: float = Field(default=1.0, description="CrossRef rate limit delay")
|
| 44 |
+
semantic_scholar_rate_limit_delay: float = Field(default=1.0, description="Semantic Scholar rate limit delay")
|
| 45 |
+
dblp_rate_limit_delay: float = Field(default=1.0, description="DBLP rate limit delay")
|
| 46 |
+
openalex_rate_limit_delay: float = Field(default=1.0, description="OpenAlex rate limit delay")
|
| 47 |
+
scholar_rate_limit_delay: float = Field(default=5.0, description="Google Scholar rate limit delay")
|
| 48 |
+
|
| 49 |
+
# API Timeouts
|
| 50 |
+
request_timeout: int = Field(default=30, description="Request timeout in seconds")
|
| 51 |
+
max_workers: int = Field(default=10, description="Max concurrent workers")
|
| 52 |
+
|
| 53 |
+
# Logging
|
| 54 |
+
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
|
| 55 |
+
default="INFO",
|
| 56 |
+
description="Logging level"
|
| 57 |
+
)
|
| 58 |
+
log_format: Literal["json", "text"] = Field(default="json", description="Log format")
|
| 59 |
+
log_file: str = Field(default="logs/citescan.log", description="Log file path")
|
| 60 |
+
|
| 61 |
+
# CORS Settings
|
| 62 |
+
cors_origins: str = Field(
|
| 63 |
+
default="http://localhost:3000,http://localhost:8080",
|
| 64 |
+
description="Comma-separated CORS origins"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Optional API Keys
|
| 68 |
+
semantic_scholar_api_key: str | None = Field(default=None, description="Semantic Scholar API key")
|
| 69 |
+
crossref_api_key: str | None = Field(default=None, description="CrossRef API key")
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def cors_origins_list(self) -> list[str]:
|
| 73 |
+
"""Parse CORS origins into a list."""
|
| 74 |
+
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def is_production(self) -> bool:
|
| 78 |
+
"""Check if running in production."""
|
| 79 |
+
return self.environment == "production"
|
| 80 |
+
|
| 81 |
+
@property
|
| 82 |
+
def is_development(self) -> bool:
|
| 83 |
+
"""Check if running in development."""
|
| 84 |
+
return self.environment == "development"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Global settings instance
|
| 88 |
+
settings = Settings()
|
src/core/exceptions.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom exceptions for CiteScan."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class CiteScanException(Exception):
|
| 5 |
+
"""Base exception for CiteScan."""
|
| 6 |
+
|
| 7 |
+
def __init__(self, message: str, details: dict | None = None):
|
| 8 |
+
"""Initialize exception.
|
| 9 |
+
|
| 10 |
+
Args:
|
| 11 |
+
message: Error message
|
| 12 |
+
details: Optional additional details
|
| 13 |
+
"""
|
| 14 |
+
self.message = message
|
| 15 |
+
self.details = details or {}
|
| 16 |
+
super().__init__(self.message)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FetcherException(CiteScanException):
|
| 20 |
+
"""Exception raised by fetchers."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, message: str, source: str, details: dict | None = None):
|
| 23 |
+
"""Initialize fetcher exception.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
message: Error message
|
| 27 |
+
source: Fetcher source (e.g., 'arxiv', 'crossref')
|
| 28 |
+
details: Optional additional details
|
| 29 |
+
"""
|
| 30 |
+
self.source = source
|
| 31 |
+
super().__init__(message, details)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ParserException(CiteScanException):
|
| 35 |
+
"""Exception raised by parsers."""
|
| 36 |
+
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ValidationException(CiteScanException):
|
| 41 |
+
"""Exception raised during validation."""
|
| 42 |
+
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class RateLimitException(FetcherException):
|
| 47 |
+
"""Exception raised when rate limit is exceeded."""
|
| 48 |
+
|
| 49 |
+
def __init__(self, source: str, retry_after: int | None = None):
|
| 50 |
+
"""Initialize rate limit exception.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
source: Fetcher source
|
| 54 |
+
retry_after: Seconds to wait before retry
|
| 55 |
+
"""
|
| 56 |
+
self.retry_after = retry_after
|
| 57 |
+
message = f"Rate limit exceeded for {source}"
|
| 58 |
+
if retry_after:
|
| 59 |
+
message += f". Retry after {retry_after} seconds"
|
| 60 |
+
super().__init__(message, source)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TimeoutException(FetcherException):
|
| 64 |
+
"""Exception raised when request times out."""
|
| 65 |
+
|
| 66 |
+
def __init__(self, source: str, timeout: int):
|
| 67 |
+
"""Initialize timeout exception.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
source: Fetcher source
|
| 71 |
+
timeout: Timeout value in seconds
|
| 72 |
+
"""
|
| 73 |
+
message = f"Request to {source} timed out after {timeout} seconds"
|
| 74 |
+
super().__init__(message, source)
|
src/core/logging.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logging configuration for CiteScan."""
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
import json
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from .config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class JSONFormatter(logging.Formatter):
|
| 13 |
+
"""Custom JSON formatter for structured logging."""
|
| 14 |
+
|
| 15 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 16 |
+
"""Format log record as JSON."""
|
| 17 |
+
log_data: dict[str, Any] = {
|
| 18 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 19 |
+
"level": record.levelname,
|
| 20 |
+
"logger": record.name,
|
| 21 |
+
"message": record.getMessage(),
|
| 22 |
+
"module": record.module,
|
| 23 |
+
"function": record.funcName,
|
| 24 |
+
"line": record.lineno,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Add exception info if present
|
| 28 |
+
if record.exc_info:
|
| 29 |
+
log_data["exception"] = self.formatException(record.exc_info)
|
| 30 |
+
|
| 31 |
+
# Add extra fields
|
| 32 |
+
if hasattr(record, "extra"):
|
| 33 |
+
log_data.update(record.extra)
|
| 34 |
+
|
| 35 |
+
return json.dumps(log_data, ensure_ascii=False)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TextFormatter(logging.Formatter):
|
| 39 |
+
"""Custom text formatter with colors for console output."""
|
| 40 |
+
|
| 41 |
+
COLORS = {
|
| 42 |
+
"DEBUG": "\033[36m", # Cyan
|
| 43 |
+
"INFO": "\033[32m", # Green
|
| 44 |
+
"WARNING": "\033[33m", # Yellow
|
| 45 |
+
"ERROR": "\033[31m", # Red
|
| 46 |
+
"CRITICAL": "\033[35m", # Magenta
|
| 47 |
+
}
|
| 48 |
+
RESET = "\033[0m"
|
| 49 |
+
|
| 50 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 51 |
+
"""Format log record with colors."""
|
| 52 |
+
color = self.COLORS.get(record.levelname, self.RESET)
|
| 53 |
+
record.levelname = f"{color}{record.levelname}{self.RESET}"
|
| 54 |
+
return super().format(record)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def setup_logging() -> None:
|
| 58 |
+
"""Setup logging configuration based on settings."""
|
| 59 |
+
# Create logs directory if it doesn't exist
|
| 60 |
+
log_file_path = Path(settings.log_file)
|
| 61 |
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
# Root logger
|
| 64 |
+
root_logger = logging.getLogger()
|
| 65 |
+
root_logger.setLevel(getattr(logging, settings.log_level))
|
| 66 |
+
|
| 67 |
+
# Remove existing handlers
|
| 68 |
+
root_logger.handlers.clear()
|
| 69 |
+
|
| 70 |
+
# Console handler
|
| 71 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 72 |
+
console_handler.setLevel(getattr(logging, settings.log_level))
|
| 73 |
+
|
| 74 |
+
if settings.log_format == "json":
|
| 75 |
+
console_formatter = JSONFormatter()
|
| 76 |
+
else:
|
| 77 |
+
console_formatter = TextFormatter(
|
| 78 |
+
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 79 |
+
datefmt="%Y-%m-%d %H:%M:%S"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
console_handler.setFormatter(console_formatter)
|
| 83 |
+
root_logger.addHandler(console_handler)
|
| 84 |
+
|
| 85 |
+
# File handler (always JSON for easier parsing)
|
| 86 |
+
file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
|
| 87 |
+
file_handler.setLevel(getattr(logging, settings.log_level))
|
| 88 |
+
file_handler.setFormatter(JSONFormatter())
|
| 89 |
+
root_logger.addHandler(file_handler)
|
| 90 |
+
|
| 91 |
+
# Reduce noise from third-party libraries
|
| 92 |
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
| 93 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 94 |
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 95 |
+
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def get_logger(name: str) -> logging.Logger:
|
| 99 |
+
"""Get a logger instance with the given name.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
name: Logger name (typically __name__)
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Logger instance
|
| 106 |
+
"""
|
| 107 |
+
return logging.getLogger(name)
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Business logic services."""
|
| 2 |
+
from .verification_service import VerificationService
|
| 3 |
+
|
| 4 |
+
__all__ = ["VerificationService"]
|
src/services/verification_service.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verification service for BibTeX entries.
|
| 2 |
+
|
| 3 |
+
This service extracts the core verification logic from app.py,
|
| 4 |
+
making it reusable for both Gradio UI and FastAPI endpoints.
|
| 5 |
+
"""
|
| 6 |
+
import tempfile
|
| 7 |
+
import threading
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from src.parsers import BibParser
|
| 14 |
+
from src.fetchers import (
|
| 15 |
+
ArxivFetcher,
|
| 16 |
+
ScholarFetcher,
|
| 17 |
+
CrossRefFetcher,
|
| 18 |
+
SemanticScholarFetcher,
|
| 19 |
+
OpenAlexFetcher,
|
| 20 |
+
DBLPFetcher,
|
| 21 |
+
)
|
| 22 |
+
from src.analyzers import MetadataComparator, DuplicateDetector
|
| 23 |
+
from src.report.generator import EntryReport
|
| 24 |
+
from src.config.workflow import get_default_workflow
|
| 25 |
+
from src.utils.normalizer import TextNormalizer
|
| 26 |
+
from src.core.config import settings
|
| 27 |
+
from src.core.logging import get_logger
|
| 28 |
+
from src.core.exceptions import ParserException, FetcherException
|
| 29 |
+
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class VerificationResult:
|
| 35 |
+
"""Result of BibTeX verification."""
|
| 36 |
+
|
| 37 |
+
entry_reports: list[EntryReport]
|
| 38 |
+
duplicate_groups: list
|
| 39 |
+
verified_count: int
|
| 40 |
+
warning_count: int
|
| 41 |
+
error_count: int
|
| 42 |
+
total_count: int
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def success_rate(self) -> float:
|
| 46 |
+
"""Calculate success rate."""
|
| 47 |
+
if self.total_count == 0:
|
| 48 |
+
return 0.0
|
| 49 |
+
return (self.verified_count / self.total_count) * 100
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class VerificationService:
|
| 53 |
+
"""Service for verifying BibTeX entries against academic databases."""
|
| 54 |
+
|
| 55 |
+
def __init__(self):
|
| 56 |
+
"""Initialize verification service."""
|
| 57 |
+
self.parser = BibParser()
|
| 58 |
+
self.arxiv_fetcher = ArxivFetcher()
|
| 59 |
+
self.crossref_fetcher = CrossRefFetcher()
|
| 60 |
+
self.scholar_fetcher = ScholarFetcher()
|
| 61 |
+
self.semantic_scholar_fetcher = SemanticScholarFetcher()
|
| 62 |
+
self.openalex_fetcher = OpenAlexFetcher()
|
| 63 |
+
self.dblp_fetcher = DBLPFetcher()
|
| 64 |
+
self.comparator = MetadataComparator()
|
| 65 |
+
self.duplicate_detector = DuplicateDetector()
|
| 66 |
+
logger.info("VerificationService initialized")
|
| 67 |
+
|
| 68 |
+
def verify_bibtex_string(
|
| 69 |
+
self,
|
| 70 |
+
bibtex_content: str,
|
| 71 |
+
progress_callback: Optional[callable] = None,
|
| 72 |
+
) -> VerificationResult:
|
| 73 |
+
"""Verify BibTeX content from string.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
bibtex_content: BibTeX content as string
|
| 77 |
+
progress_callback: Optional callback for progress updates (progress, desc)
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
VerificationResult with all verification data
|
| 81 |
+
|
| 82 |
+
Raises:
|
| 83 |
+
ParserException: If BibTeX parsing fails
|
| 84 |
+
FetcherException: If fetching fails
|
| 85 |
+
"""
|
| 86 |
+
if not bibtex_content.strip():
|
| 87 |
+
raise ParserException("Empty BibTeX content provided")
|
| 88 |
+
|
| 89 |
+
logger.info("Starting BibTeX verification")
|
| 90 |
+
|
| 91 |
+
# Parse BibTeX
|
| 92 |
+
try:
|
| 93 |
+
if progress_callback:
|
| 94 |
+
progress_callback(0, "Parsing BibTeX...")
|
| 95 |
+
|
| 96 |
+
# Write to temporary file
|
| 97 |
+
with tempfile.NamedTemporaryFile(
|
| 98 |
+
mode="w", suffix=".bib", delete=False, encoding="utf-8"
|
| 99 |
+
) as f:
|
| 100 |
+
f.write(bibtex_content)
|
| 101 |
+
temp_bib_path = f.name
|
| 102 |
+
|
| 103 |
+
entries = self.parser.parse_file(temp_bib_path)
|
| 104 |
+
Path(temp_bib_path).unlink() # Delete temp file
|
| 105 |
+
|
| 106 |
+
if not entries:
|
| 107 |
+
raise ParserException("No valid BibTeX entries found")
|
| 108 |
+
|
| 109 |
+
logger.info(f"Parsed {len(entries)} BibTeX entries")
|
| 110 |
+
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error(f"BibTeX parsing failed: {e}")
|
| 113 |
+
raise ParserException(f"Failed to parse BibTeX: {str(e)}")
|
| 114 |
+
|
| 115 |
+
# Detect duplicates
|
| 116 |
+
duplicate_groups = self.duplicate_detector.find_duplicates(entries)
|
| 117 |
+
if duplicate_groups:
|
| 118 |
+
logger.warning(f"Found {len(duplicate_groups)} duplicate groups")
|
| 119 |
+
|
| 120 |
+
# Get workflow configuration
|
| 121 |
+
workflow_config = get_default_workflow()
|
| 122 |
+
|
| 123 |
+
# Process entries
|
| 124 |
+
entry_reports = []
|
| 125 |
+
progress_lock = threading.Lock()
|
| 126 |
+
verified_count = 0
|
| 127 |
+
warning_count = 0
|
| 128 |
+
error_count = 0
|
| 129 |
+
|
| 130 |
+
if progress_callback:
|
| 131 |
+
progress_callback(0.1, "Initializing fetchers...")
|
| 132 |
+
|
| 133 |
+
def process_single_entry(entry, idx, total):
|
| 134 |
+
"""Process a single BibTeX entry."""
|
| 135 |
+
comparison_result = None
|
| 136 |
+
all_results = []
|
| 137 |
+
|
| 138 |
+
for step in workflow_config.get_enabled_steps():
|
| 139 |
+
result = None
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
if step.name == "arxiv_id" and entry.has_arxiv and self.arxiv_fetcher:
|
| 143 |
+
arxiv_meta = self.arxiv_fetcher.fetch_by_id(entry.arxiv_id)
|
| 144 |
+
if arxiv_meta:
|
| 145 |
+
result = self.comparator.compare_with_arxiv(entry, arxiv_meta)
|
| 146 |
+
|
| 147 |
+
elif step.name == "crossref_doi" and entry.doi and self.crossref_fetcher:
|
| 148 |
+
crossref_result = self.crossref_fetcher.search_by_doi(entry.doi)
|
| 149 |
+
if crossref_result:
|
| 150 |
+
result = self.comparator.compare_with_crossref(entry, crossref_result)
|
| 151 |
+
|
| 152 |
+
elif step.name == "semantic_scholar" and entry.title and self.semantic_scholar_fetcher:
|
| 153 |
+
ss_result = (
|
| 154 |
+
self.semantic_scholar_fetcher.fetch_by_doi(entry.doi)
|
| 155 |
+
if entry.doi
|
| 156 |
+
else None
|
| 157 |
+
)
|
| 158 |
+
if not ss_result:
|
| 159 |
+
ss_result = self.semantic_scholar_fetcher.search_by_title(entry.title)
|
| 160 |
+
if ss_result:
|
| 161 |
+
result = self.comparator.compare_with_semantic_scholar(entry, ss_result)
|
| 162 |
+
|
| 163 |
+
elif step.name == "dblp" and entry.title and self.dblp_fetcher:
|
| 164 |
+
dblp_result = self.dblp_fetcher.search_by_title(entry.title)
|
| 165 |
+
if dblp_result:
|
| 166 |
+
result = self.comparator.compare_with_dblp(entry, dblp_result)
|
| 167 |
+
|
| 168 |
+
elif step.name == "openalex" and entry.title and self.openalex_fetcher:
|
| 169 |
+
oa_result = (
|
| 170 |
+
self.openalex_fetcher.fetch_by_doi(entry.doi)
|
| 171 |
+
if entry.doi
|
| 172 |
+
else None
|
| 173 |
+
)
|
| 174 |
+
if not oa_result:
|
| 175 |
+
oa_result = self.openalex_fetcher.search_by_title(entry.title)
|
| 176 |
+
if oa_result:
|
| 177 |
+
result = self.comparator.compare_with_openalex(entry, oa_result)
|
| 178 |
+
|
| 179 |
+
elif step.name == "arxiv_title" and entry.title and self.arxiv_fetcher:
|
| 180 |
+
results = self.arxiv_fetcher.search_by_title(entry.title, max_results=3)
|
| 181 |
+
if results:
|
| 182 |
+
best_result = None
|
| 183 |
+
best_sim = 0.0
|
| 184 |
+
norm1 = TextNormalizer.normalize_for_comparison(entry.title)
|
| 185 |
+
for r in results:
|
| 186 |
+
sim = TextNormalizer.similarity_ratio(
|
| 187 |
+
norm1,
|
| 188 |
+
TextNormalizer.normalize_for_comparison(r.title),
|
| 189 |
+
)
|
| 190 |
+
if sim > best_sim:
|
| 191 |
+
best_sim, best_result = sim, r
|
| 192 |
+
if best_result and best_sim > 0.5:
|
| 193 |
+
result = self.comparator.compare_with_arxiv(entry, best_result)
|
| 194 |
+
|
| 195 |
+
elif step.name == "crossref_title" and entry.title and self.crossref_fetcher:
|
| 196 |
+
crossref_result = self.crossref_fetcher.search_by_title(entry.title)
|
| 197 |
+
if crossref_result:
|
| 198 |
+
result = self.comparator.compare_with_crossref(entry, crossref_result)
|
| 199 |
+
|
| 200 |
+
elif step.name == "google_scholar" and entry.title and self.scholar_fetcher:
|
| 201 |
+
scholar_result = self.scholar_fetcher.search_by_title(entry.title)
|
| 202 |
+
if scholar_result:
|
| 203 |
+
result = self.comparator.compare_with_scholar(entry, scholar_result)
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.warning(f"Error in step {step.name} for entry {entry.key}: {e}")
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
if result:
|
| 210 |
+
all_results.append(result)
|
| 211 |
+
if result.is_match:
|
| 212 |
+
comparison_result = result
|
| 213 |
+
break
|
| 214 |
+
|
| 215 |
+
# Select best result if no perfect match
|
| 216 |
+
if not comparison_result and all_results:
|
| 217 |
+
all_results.sort(key=lambda r: r.confidence, reverse=True)
|
| 218 |
+
comparison_result = all_results[0]
|
| 219 |
+
elif not comparison_result:
|
| 220 |
+
comparison_result = self.comparator.create_unable_result(
|
| 221 |
+
entry, "Unable to find this paper in any data source"
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
return EntryReport(entry=entry, comparison=comparison_result)
|
| 225 |
+
|
| 226 |
+
# Process entries concurrently
|
| 227 |
+
max_workers = min(settings.max_workers, len(entries))
|
| 228 |
+
logger.info(f"Processing {len(entries)} entries with {max_workers} workers")
|
| 229 |
+
|
| 230 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 231 |
+
future_to_entry = {
|
| 232 |
+
executor.submit(process_single_entry, e, i, len(entries)): (e, i)
|
| 233 |
+
for i, e in enumerate(entries)
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
for future in as_completed(future_to_entry):
|
| 237 |
+
entry, idx = future_to_entry[future]
|
| 238 |
+
try:
|
| 239 |
+
entry_report = future.result()
|
| 240 |
+
with progress_lock:
|
| 241 |
+
entry_reports.append(entry_report)
|
| 242 |
+
|
| 243 |
+
if entry_report.comparison and entry_report.comparison.is_match:
|
| 244 |
+
verified_count += 1
|
| 245 |
+
elif entry_report.comparison and entry_report.comparison.has_issues:
|
| 246 |
+
warning_count += 1
|
| 247 |
+
else:
|
| 248 |
+
error_count += 1
|
| 249 |
+
|
| 250 |
+
if progress_callback:
|
| 251 |
+
progress_callback(
|
| 252 |
+
0.1 + (0.9 * (idx + 1) / len(entries)),
|
| 253 |
+
f"Verifying entries {idx + 1}/{len(entries)}...",
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
with progress_lock:
|
| 258 |
+
error_count += 1
|
| 259 |
+
logger.error(f"Error processing entry {entry.key}: {e}")
|
| 260 |
+
|
| 261 |
+
logger.info(
|
| 262 |
+
f"Verification complete: {verified_count} verified, "
|
| 263 |
+
f"{warning_count} warnings, {error_count} errors"
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
return VerificationResult(
|
| 267 |
+
entry_reports=entry_reports,
|
| 268 |
+
duplicate_groups=duplicate_groups,
|
| 269 |
+
verified_count=verified_count,
|
| 270 |
+
warning_count=warning_count,
|
| 271 |
+
error_count=error_count,
|
| 272 |
+
total_count=len(entries),
|
| 273 |
+
)
|
start.sh
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Startup script for running both Gradio and API services
|
| 3 |
+
|
| 4 |
+
set -e
|
| 5 |
+
|
| 6 |
+
# Load environment variables
|
| 7 |
+
if [ -f .env ]; then
|
| 8 |
+
export $(cat .env | grep -v '^#' | xargs)
|
| 9 |
+
fi
|
| 10 |
+
|
| 11 |
+
# Default mode
|
| 12 |
+
MODE=${1:-"both"}
|
| 13 |
+
|
| 14 |
+
case "$MODE" in
|
| 15 |
+
"api")
|
| 16 |
+
echo "Starting FastAPI service..."
|
| 17 |
+
python main.py
|
| 18 |
+
;;
|
| 19 |
+
"gradio")
|
| 20 |
+
echo "Starting Gradio service..."
|
| 21 |
+
python app.py
|
| 22 |
+
;;
|
| 23 |
+
"both")
|
| 24 |
+
echo "Starting both services..."
|
| 25 |
+
python main.py &
|
| 26 |
+
API_PID=$!
|
| 27 |
+
python app.py &
|
| 28 |
+
GRADIO_PID=$!
|
| 29 |
+
|
| 30 |
+
# Wait for both processes
|
| 31 |
+
wait $API_PID
|
| 32 |
+
wait $GRADIO_PID
|
| 33 |
+
;;
|
| 34 |
+
*)
|
| 35 |
+
echo "Usage: $0 {api|gradio|both}"
|
| 36 |
+
exit 1
|
| 37 |
+
;;
|
| 38 |
+
esac
|