aivolcano commited on
Commit
3d83b62
·
1 Parent(s): ec7bc6b

FastAPI + Gradio + src

Browse files
.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
- <!-- - **🎉 CiteScan is now available online. [https://huggingface.co/spaces/thinkwee/CiteScan](https://huggingface.co/spaces/thinkwee/CiteScan)! 🎉** -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
 
 
 
 
 
 
 
 
5
 
6
- **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.
 
 
 
 
 
 
7
 
8
- Going beyond simple verification, **CiteScan** used 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**.
9
 
 
 
10
 
11
  ## 🛡 Why CiteScan?
12
 
13
- - **🚫 NO Hallucinations**: Annotate citations that don't exist or have mismatched metadata across year, authors, and title.
 
 
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
  ![Functions](assets/screenshot_performance_zh.png)
18
 
19
- - **🏠 Top-tier Research Organizations**: Cooperate with National University of Singapore (NUS) and Shanghai Jiao Tong University (SJTU).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- ## References Validation
22
- - **Multi-Source Verification**: Validates metadata against arXiv, CrossRef, DBLP, Semantic Scholar, OpenAlex, and Google Scholar
23
 
 
24
 
 
25
 
26
  ![Citation](assets/screenshot_semantic_scholar.png)
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- ### *Case Study for False Positive*
 
 
 
 
 
 
 
 
 
 
30
 
31
- 1. **Authors Mismatch**:
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
- 2. **Venues Mismatch**:
36
- - *Reason*: Abbreviations vs. full names, such as "ICLR" v.s. "International Conference on Learning Representations"
37
- - *Action*: Both are correct.
38
 
39
- 3. **Year GAP (±1 Year)**:
40
- - *Reason*: Delay between preprint (arXiv) and final version publication
41
- - *Action*: Verify which version you intend to cite, We recommend you to cite the version from the official press website. Lower pre-print version bib will make your submission more confidence.
42
 
43
- 4. **Non-academic Sources**:
44
- - *Reason*: Blogs, and APIs are not indexed in academic databases.
45
- - *Action*: Verify URL, year, and title manually.
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
- ### Push
59
- ```shell
60
 
61
- # 如果已经添加过 modelscope 远程,先删掉再加:
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
- # 添加 ModelScope 为远程仓库
66
- git remote add modelscope "http://oauth2:ms-28735aa7-04b6-4b21-b4e0-cfb464f3587f@www.modelscope.cn/studios/aivolcano/CiteScan.git"
67
 
68
- # 推送到创空间(主分支是 main 时)
69
- git push modelscope main
70
 
71
- # 推送到创空间(主分支是 master 时)
72
- git push modelscope master
 
73
 
 
74
 
75
- # 若推送时报错
76
- git push modelscope main --force
 
 
 
 
 
 
 
 
77
 
78
- # 推送成功后
79
- https://www.modelscope.cn/studios/aivolcano/CiteScan
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
  ![Functions](assets/screenshot_performance_zh.png)
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
  ![Citation](assets/screenshot_semantic_scholar.png)
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