First Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
IEEE_API_KEY=
|
||||
SEMANTIC_SCHOLAR_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
CROSSREF_MAILTO=your_email@example.com
|
||||
GMAIL_ADDRESS=
|
||||
GMAIL_APP_PASSWORD=
|
||||
JOPLIN_TOKEN=
|
||||
@@ -0,0 +1,29 @@
|
||||
# =========================
|
||||
# Paper sources
|
||||
# =========================
|
||||
IEEE_API_KEY=
|
||||
SEMANTIC_SCHOLAR_API_KEY=
|
||||
CROSSREF_MAILTO=your_email@example.com
|
||||
OPENAI_API_KEY=
|
||||
GMAIL_ADDRESS=
|
||||
GMAIL_APP_PASSWORD=
|
||||
|
||||
# =========================
|
||||
# Joplin WebDAV
|
||||
# IMPORTANT: Use the same WebDAV sync folder that your existing Joplin clients use.
|
||||
# Example: https://your-nas-hostname:5006/Joplin
|
||||
# =========================
|
||||
JOPLIN_WEBDAV_URL=https://YOUR_NAS:5006/Joplin
|
||||
JOPLIN_WEBDAV_USERNAME=joplin-sync
|
||||
JOPLIN_WEBDAV_PASSWORD=CHANGE_ME
|
||||
|
||||
# Notebook created/used by the NAS automation
|
||||
JOPLIN_NOTEBOOK=Radar Papers
|
||||
|
||||
# First run: keep false and verify that Joplin CLI can sync existing notes.
|
||||
# After verification change to true.
|
||||
JOPLIN_WRITE_ENABLED=false
|
||||
|
||||
# Keep false for a normal valid HTTPS certificate.
|
||||
# Only consider true if you intentionally use a self-signed certificate.
|
||||
JOPLIN_IGNORE_TLS_ERRORS=false
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
joplin-profile/
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src ./src
|
||||
COPY config.example.yaml ./config.yaml
|
||||
ENV PYTHONPATH=/app/src
|
||||
CMD ["python", "-m", "paper_monitor", "--config", "/app/config.yaml"]
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH=/opt/venv/bin:$PATH
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-venv python3-pip ca-certificates tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Joplin Terminal (official npm package)
|
||||
RUN npm install --loglevel=error -g joplin
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml requirements.txt ./
|
||||
COPY src ./src
|
||||
RUN python3 -m venv /opt/venv \
|
||||
&& pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
COPY config.nas.yaml /app/config.yaml
|
||||
COPY nas /app/nas
|
||||
RUN chmod +x /app/nas/*.sh
|
||||
|
||||
VOLUME ["/app/data", "/joplin-profile"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/app/nas/run_once.sh"]
|
||||
@@ -0,0 +1,136 @@
|
||||
# Paper Monitor → Joplin
|
||||
|
||||
자동차 레이더 관련 신규 논문을 IEEE Xplore, Semantic Scholar, Crossref, Google Scholar Alert에서 수집하고 DOI/제목 기준으로 중복 제거한 뒤 Markdown 표와 Joplin note로 만드는 프로젝트입니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
```bash
|
||||
cd paper-monitor
|
||||
cp config.example.yaml config.yaml
|
||||
cp .env.example .env
|
||||
python -m venv .venv
|
||||
# Linux/macOS: source .venv/bin/activate
|
||||
# Windows: .venv\Scripts\activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
환경변수에 필요한 키를 설정합니다.
|
||||
|
||||
```text
|
||||
IEEE_API_KEY=...
|
||||
SEMANTIC_SCHOLAR_API_KEY=...
|
||||
CROSSREF_MAILTO=you@example.com
|
||||
JOPLIN_TOKEN=...
|
||||
```
|
||||
|
||||
실행:
|
||||
|
||||
```bash
|
||||
python -m paper_monitor --config config.yaml --dry-run
|
||||
python -m paper_monitor --config config.yaml
|
||||
```
|
||||
|
||||
`--dry-run`은 Joplin 쓰기만 생략합니다. SQLite와 Markdown 생성은 수행합니다.
|
||||
|
||||
## 동작
|
||||
|
||||
1. `config.yaml`의 각 검색어를 소스별로 조회
|
||||
2. 최근 `lookback_days` 필터
|
||||
3. DOI 우선, DOI 없으면 normalized-title hash로 중복 제거
|
||||
4. category와 relevance 1~5 계산
|
||||
5. 선택적으로 OpenAI API로 한국어 요약/relevance/category 보강
|
||||
6. `data/papers.db`에 이미 본 논문 저장
|
||||
7. 처음 발견한 논문만 `data/outbox/*.md`에 출력
|
||||
8. Joplin Data API가 사용 가능하면 `Research/Radar Papers` note 자동 생성/갱신
|
||||
|
||||
## Google Scholar
|
||||
|
||||
Scholar 검색 결과 HTML을 직접 scraping하지 않습니다. Google Scholar Alert를 Gmail로 받은 뒤 IMAP으로 읽습니다. `google_scholar_alert.enabled: true`로 켜고 아래 변수를 설정합니다.
|
||||
|
||||
```text
|
||||
GMAIL_ADDRESS=...
|
||||
GMAIL_APP_PASSWORD=...
|
||||
```
|
||||
|
||||
Google은 일반 계정 비밀번호를 IMAP 클라이언트에 직접 공유하는 방식을 권장하지 않습니다. 개인 계정에서 사용할 수 있는 경우 2단계 인증 + App Password를 쓰거나, 장기 운영에서는 Gmail OAuth/Gmail API로 collector를 교체하는 편이 좋습니다.
|
||||
|
||||
## Joplin
|
||||
|
||||
기본 출력은 Joplin Data API입니다. Joplin Desktop에서 Web Clipper service를 켜고 token을 발급한 뒤:
|
||||
|
||||
```yaml
|
||||
joplin:
|
||||
enabled: true
|
||||
base_url: http://127.0.0.1:41184
|
||||
token_env: JOPLIN_TOKEN
|
||||
```
|
||||
|
||||
Joplin API가 실패해도 Markdown은 항상 `data/outbox`에 남습니다.
|
||||
|
||||
### NAS에서 주의
|
||||
|
||||
NAS Docker 내부의 `127.0.0.1:41184`는 Windows PC가 아니라 NAS 컨테이너 자신입니다. 따라서 가장 간단한 1차 구성은 **Windows Task Scheduler + Joplin Desktop/Web Clipper + 이 프로그램**입니다. 논문 수집만 NAS에서 돌리고 Markdown을 NAS에 보관하는 것도 가능합니다.
|
||||
|
||||
Joplin 공식 Terminal 앱은 WebDAV sync와 cron 실행을 지원하므로, 향후 완전 NAS-only 구성으로 바꿀 때 사용할 수 있습니다.
|
||||
|
||||
## AI 요약
|
||||
|
||||
기본은 off입니다.
|
||||
|
||||
```yaml
|
||||
ai:
|
||||
enabled: true
|
||||
api_key_env: OPENAI_API_KEY
|
||||
model: gpt-5-mini
|
||||
max_papers_per_run: 30
|
||||
```
|
||||
|
||||
AI 호출이 실패해도 keyword 기반 결과로 계속 진행됩니다.
|
||||
|
||||
## Docker / Synology Container Manager
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
cp .env.example .env
|
||||
docker compose build
|
||||
docker compose run --rm paper-monitor
|
||||
```
|
||||
|
||||
매일 06:00 cron 예:
|
||||
|
||||
```cron
|
||||
0 6 * * * cd /volume1/docker/paper-monitor && /usr/bin/docker compose run --rm paper-monitor >> ./data/cron.log 2>&1
|
||||
```
|
||||
|
||||
Synology Task Scheduler에서도 같은 명령을 User-defined script로 등록할 수 있습니다. 실제 docker 경로는 NAS에서 `which docker`로 확인하세요.
|
||||
|
||||
## 결과 형식
|
||||
|
||||
```markdown
|
||||
| Relevance | Category | Paper | Authors | Venue | Date | Citations | Source |
|
||||
|---|---|---|---|---|---|---:|---|
|
||||
| ★★★★★ | Imaging Radar, MIMO | [Paper title](...) | ... | IEEE TAP | 2026-08-11 | 2 | IEEE Xplore |
|
||||
```
|
||||
|
||||
표 아래에 논문별 DOI, 링크, abstract excerpt 또는 AI summary가 추가됩니다.
|
||||
|
||||
## 권장 초기 설정
|
||||
|
||||
처음 1~2주는 AI와 Scholar Alert를 끄고 IEEE + Semantic Scholar + Crossref만 사용해 noise를 확인하는 것을 권장합니다.
|
||||
|
||||
```yaml
|
||||
app:
|
||||
lookback_days: 14
|
||||
min_relevance: 2
|
||||
ai:
|
||||
enabled: false
|
||||
sources:
|
||||
google_scholar_alert:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
그 후 `queries`, `categories`, `relevance_terms`를 본인 관심 분야에 맞게 조정하면 됩니다.
|
||||
|
||||
## 보안
|
||||
|
||||
API key, Gmail App Password, Joplin token은 Git에 커밋하지 마세요. 환경변수/Docker secret 등을 사용하세요.
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
# Synology NAS-only Paper Monitor → Joplin
|
||||
|
||||
이 구성은 Windows PC가 꺼져 있어도 Synology NAS만으로 다음 작업을 수행합니다.
|
||||
|
||||
```text
|
||||
Paper APIs
|
||||
↓
|
||||
Python paper-monitor
|
||||
↓
|
||||
SQLite deduplication
|
||||
↓
|
||||
Daily Markdown report
|
||||
↓
|
||||
Joplin Terminal import
|
||||
↓
|
||||
Joplin WebDAV sync
|
||||
↓
|
||||
Synology WebDAV sync folder
|
||||
↓
|
||||
PC / phone / tablet Joplin
|
||||
```
|
||||
|
||||
## 중요한 원칙
|
||||
|
||||
NAS의 Joplin WebDAV 동기화 폴더에 Markdown 파일을 직접 복사하지 않습니다.
|
||||
반드시 Joplin Terminal을 통해 note를 만들고 `joplin sync`를 실행합니다.
|
||||
|
||||
`joplin-profile/`은 NAS의 Joplin CLI 전용 로컬 profile입니다. PC Joplin profile과 공유하지 마세요.
|
||||
|
||||
---
|
||||
|
||||
## 1. NAS에 프로젝트 복사
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
/volume1/docker/paper-monitor/
|
||||
```
|
||||
|
||||
이 폴더에 이 ZIP의 전체 파일을 복사합니다.
|
||||
|
||||
최소 파일:
|
||||
|
||||
```text
|
||||
paper-monitor/
|
||||
├── Dockerfile.nas
|
||||
├── docker-compose.nas.yml
|
||||
├── config.nas.yaml
|
||||
├── .env
|
||||
├── src/
|
||||
├── nas/
|
||||
├── data/
|
||||
└── joplin-profile/
|
||||
```
|
||||
|
||||
`data`와 `joplin-profile` 폴더는 없으면 Docker 실행 시 생성할 수 있지만, 미리 만드는 것을 권장합니다.
|
||||
|
||||
```bash
|
||||
mkdir -p /volume1/docker/paper-monitor/data
|
||||
mkdir -p /volume1/docker/paper-monitor/joplin-profile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. .env 생성
|
||||
|
||||
`.env.nas.example`을 복사하여 `.env`로 만듭니다.
|
||||
|
||||
```bash
|
||||
cp .env.nas.example .env
|
||||
```
|
||||
|
||||
예:
|
||||
|
||||
```env
|
||||
IEEE_API_KEY=
|
||||
SEMANTIC_SCHOLAR_API_KEY=
|
||||
CROSSREF_MAILTO=your_email@example.com
|
||||
|
||||
JOPLIN_WEBDAV_URL=https://your-nas-hostname:5006/Joplin
|
||||
JOPLIN_WEBDAV_USERNAME=joplin-sync
|
||||
JOPLIN_WEBDAV_PASSWORD=YOUR_PASSWORD
|
||||
JOPLIN_NOTEBOOK=Radar Papers
|
||||
JOPLIN_WRITE_ENABLED=false
|
||||
JOPLIN_IGNORE_TLS_ERRORS=false
|
||||
```
|
||||
|
||||
### JOPLIN_WEBDAV_URL
|
||||
|
||||
현재 PC/스마트폰 Joplin에서 사용 중인 **동일한 Joplin WebDAV sync folder**를 지정해야 합니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
https://your-nas-hostname:5006/Joplin
|
||||
```
|
||||
|
||||
NAS Docker 컨테이너에서 해당 주소에 접근할 수 있어야 합니다.
|
||||
|
||||
### 보안
|
||||
|
||||
`.env`에는 WebDAV 비밀번호와 API key가 있으므로 Git에 올리지 마세요.
|
||||
|
||||
---
|
||||
|
||||
## 3. 처음에는 쓰기 기능을 끈 상태로 실행
|
||||
|
||||
처음에는 반드시:
|
||||
|
||||
```env
|
||||
JOPLIN_WRITE_ENABLED=false
|
||||
```
|
||||
|
||||
로 두는 것을 권장합니다.
|
||||
|
||||
이 상태에서는 **기존 Joplin WebDAV 데이터를 Joplin CLI profile로 동기화만 하고 새 note는 만들지 않습니다.**
|
||||
|
||||
가능하면 첫 테스트 전에 NAS의 Joplin WebDAV 폴더를 백업하세요.
|
||||
|
||||
---
|
||||
|
||||
## 4. Docker image 빌드
|
||||
|
||||
NAS SSH에서:
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/paper-monitor
|
||||
sudo docker compose -f docker-compose.nas.yml build
|
||||
```
|
||||
|
||||
Joplin Terminal npm package와 Python paper-monitor가 한 image에 설치됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Joplin WebDAV sync-only 테스트
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.nas.yml run --rm paper-monitor-nas /app/nas/test_sync.sh
|
||||
```
|
||||
|
||||
정상적이면 `joplin sync`가 완료되고 Joplin status가 출력됩니다.
|
||||
|
||||
이때 다음 폴더에 NAS용 Joplin local profile이 저장됩니다.
|
||||
|
||||
```text
|
||||
/volume1/docker/paper-monitor/joplin-profile/
|
||||
```
|
||||
|
||||
이 profile은 이후 실행에서도 유지됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 논문 자동 작성 활성화
|
||||
|
||||
sync-only 테스트가 정상이라면 `.env`를 수정합니다.
|
||||
|
||||
```env
|
||||
JOPLIN_WRITE_ENABLED=true
|
||||
```
|
||||
|
||||
현재 IEEE key가 아직 Waiting이라면 `config.nas.yaml`에서:
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Semantic Scholar key도 아직 승인되지 않았다면:
|
||||
|
||||
```yaml
|
||||
semantic_scholar:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
Crossref만으로도 전체 동작을 시험할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 1회 전체 테스트
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.nas.yml run --rm paper-monitor-nas
|
||||
```
|
||||
|
||||
동작 순서:
|
||||
|
||||
```text
|
||||
1. Joplin CLI → WebDAV sync (remote 변경 먼저 가져오기)
|
||||
2. 논문 API 검색
|
||||
3. SQLite 중복 제거
|
||||
4. 오늘 발견한 논문으로 일일 누적 Markdown 생성
|
||||
5. Joplin의 동일 날짜 report가 있으면 교체
|
||||
6. Markdown을 Joplin notebook으로 import
|
||||
7. Joplin CLI → WebDAV sync
|
||||
```
|
||||
|
||||
정상 로그 예:
|
||||
|
||||
```text
|
||||
Synchronising Joplin from WebDAV before writing...
|
||||
Running paper monitor...
|
||||
Crossref 'automotive radar' -> 10
|
||||
...
|
||||
Importing report into Joplin notebook: Radar Papers
|
||||
Synchronising Joplin changes to WebDAV...
|
||||
Completed: Radar Literature - 2026-08-12 (... papers)
|
||||
```
|
||||
|
||||
Joplin에서 다음 note가 보이면 성공입니다.
|
||||
|
||||
```text
|
||||
Radar Papers
|
||||
└── Radar Literature - 2026-08-12
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 같은 날 여러 번 실행해도 괜찮은 이유
|
||||
|
||||
`papers.db`에 논문별 최초 발견 날짜를 저장합니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
06:00 실행: 논문 A, B 발견
|
||||
→ 오늘 report = A, B
|
||||
|
||||
12:00 재실행: 논문 C 추가 발견
|
||||
→ 오늘 report = A, B, C
|
||||
```
|
||||
|
||||
따라서 같은 날짜의 Joplin report를 교체해도 앞서 발견한 논문이 사라지지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Synology Task Scheduler에서 매일 자동 실행
|
||||
|
||||
DSM:
|
||||
|
||||
```text
|
||||
Control Panel
|
||||
→ Task Scheduler
|
||||
→ Create
|
||||
→ Scheduled Task
|
||||
→ User-defined script
|
||||
```
|
||||
|
||||
예를 들어 매일 오전 06:00으로 설정합니다.
|
||||
|
||||
User-defined script:
|
||||
|
||||
```bash
|
||||
/volume1/docker/paper-monitor/run_scheduled.sh
|
||||
```
|
||||
|
||||
NAS에 따라 Docker 경로가 다를 수 있습니다.
|
||||
|
||||
SSH에서 확인:
|
||||
|
||||
```bash
|
||||
which docker
|
||||
```
|
||||
|
||||
출력된 경로로 `/usr/local/bin/docker`를 교체하세요.
|
||||
|
||||
---
|
||||
|
||||
## 10. 자동 실행 로그 확인
|
||||
|
||||
```bash
|
||||
ls -1t /volume1/docker/paper-monitor/data/logs/scheduler-*.log | head -n 1
|
||||
tail -n 200 "$(ls -1t /volume1/docker/paper-monitor/data/logs/scheduler-*.log | head -n 1)"
|
||||
```
|
||||
|
||||
논문 수집 결과:
|
||||
|
||||
```text
|
||||
/volume1/docker/paper-monitor/data/papers.db
|
||||
/volume1/docker/paper-monitor/data/outbox/
|
||||
/volume1/docker/paper-monitor/data/last_result.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. API 활성화
|
||||
|
||||
### IEEE
|
||||
|
||||
IEEE developer application이 Active가 된 뒤:
|
||||
|
||||
`.env`:
|
||||
|
||||
```env
|
||||
IEEE_API_KEY=YOUR_KEY
|
||||
```
|
||||
|
||||
`config.nas.yaml`:
|
||||
|
||||
```yaml
|
||||
ieee:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
401/403이 발생하면 해당 실행에서 IEEE query는 한 번 실패한 뒤 중단하도록 수정되어 있습니다.
|
||||
|
||||
### Semantic Scholar
|
||||
|
||||
API key 승인 후:
|
||||
|
||||
```env
|
||||
SEMANTIC_SCHOLAR_API_KEY=YOUR_KEY
|
||||
```
|
||||
|
||||
그리고:
|
||||
|
||||
```yaml
|
||||
semantic_scholar:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
HTTP 429가 발생하면 exponential backoff로 재시도하고, 계속 제한되면 해당 실행의 Semantic Scholar query를 중단합니다.
|
||||
|
||||
---
|
||||
|
||||
## 12. HTTPS 인증서 문제
|
||||
|
||||
정상적인 공인 인증서를 사용한다면:
|
||||
|
||||
```env
|
||||
JOPLIN_IGNORE_TLS_ERRORS=false
|
||||
```
|
||||
|
||||
를 유지하세요.
|
||||
|
||||
자체 서명 인증서 때문에 sync가 실패할 때만 임시 진단 목적으로:
|
||||
|
||||
```env
|
||||
JOPLIN_IGNORE_TLS_ERRORS=true
|
||||
```
|
||||
|
||||
를 사용할 수 있습니다. 가능하면 인증서를 정상 구성하는 것이 우선입니다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Joplin E2EE를 사용하는 경우
|
||||
|
||||
기존 Joplin 동기화에 End-to-End Encryption을 활성화해 둔 경우, 새 NAS Joplin CLI client에서도 master key/password 설정이 필요할 수 있습니다.
|
||||
|
||||
그 경우 자동 쓰기를 켜기 전에 Joplin CLI의 E2EE 상태와 복호화를 먼저 설정해야 합니다.
|
||||
|
||||
```bash
|
||||
sudo docker compose -f docker-compose.nas.yml run --rm paper-monitor-nas \
|
||||
joplin --profile /joplin-profile e2ee status
|
||||
```
|
||||
|
||||
E2EE를 사용하지 않는 경우에는 이 단계가 필요 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 14. 운영 시 권장 구성
|
||||
|
||||
```yaml
|
||||
app:
|
||||
lookback_days: 14
|
||||
min_relevance: 2
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: true # 승인 후
|
||||
semantic_scholar:
|
||||
enabled: true # 승인 후
|
||||
crossref:
|
||||
enabled: true
|
||||
|
||||
ai:
|
||||
enabled: false # 처음에는 비용 없는 상태로 운영
|
||||
|
||||
joplin:
|
||||
enabled: false # NAS에서는 Joplin CLI wrapper가 담당
|
||||
```
|
||||
|
||||
Joplin Data API용 `joplin.py`는 Windows Desktop 방식 호환을 위해 프로젝트에 유지하지만 NAS 경로에서는 사용하지 않습니다.
|
||||
@@ -0,0 +1,82 @@
|
||||
app:
|
||||
timezone: Asia/Seoul
|
||||
lookback_days: 14
|
||||
max_papers_per_source_per_query: 50
|
||||
min_relevance: 2
|
||||
output_dir: ./data/outbox
|
||||
database_path: ./data/papers.db
|
||||
log_level: INFO
|
||||
|
||||
search:
|
||||
queries:
|
||||
- automotive radar
|
||||
- automotive imaging radar
|
||||
- 4D radar
|
||||
- FMCW radar MIMO
|
||||
- radar DOA estimation
|
||||
- wideband DOA radar
|
||||
- automotive radar antenna
|
||||
- radar target simulation
|
||||
- radar interference
|
||||
- radar phase noise
|
||||
categories:
|
||||
Antenna: [antenna, array, gap waveguide, gapwaveguide, radome, beamforming, radiation pattern, sidelobe]
|
||||
Waveform: [waveform, FMCW, chirp, stepped frequency, frequency step, PMCW, phase coded]
|
||||
DOA: [DOA, direction of arrival, angle estimation, MUSIC, ESPRIT, angle FFT, beamspace]
|
||||
MIMO: [MIMO, TDM, BPM, DDMA, virtual array, multiplexing]
|
||||
Target Modeling: [target model, target simulation, extended target, scatterer, scattering center, Swerling, RCS]
|
||||
Interference: [interference, mutual interference, interference mitigation, coexistence]
|
||||
RF Impairment: [phase noise, nonlinearity, IQ imbalance, leakage, oscillator]
|
||||
Imaging Radar: [imaging radar, 4D radar, high resolution radar, point cloud]
|
||||
relevance_terms:
|
||||
automotive radar: 3
|
||||
imaging radar: 3
|
||||
4D radar: 3
|
||||
FMCW: 2
|
||||
MIMO: 2
|
||||
DOA: 2
|
||||
direction of arrival: 2
|
||||
antenna: 1
|
||||
target simulation: 2
|
||||
phase noise: 2
|
||||
interference: 1
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: true
|
||||
api_key_env: IEEE_API_KEY
|
||||
base_url: https://ieeexploreapi.ieee.org/api/v1/search/articles
|
||||
semantic_scholar:
|
||||
enabled: true
|
||||
api_key_env: SEMANTIC_SCHOLAR_API_KEY
|
||||
base_url: https://api.semanticscholar.org/graph/v1/paper/search
|
||||
crossref:
|
||||
enabled: true
|
||||
mailto_env: CROSSREF_MAILTO
|
||||
base_url: https://api.crossref.org/works
|
||||
google_scholar_alert:
|
||||
enabled: false
|
||||
gmail_address_env: GMAIL_ADDRESS
|
||||
gmail_app_password_env: GMAIL_APP_PASSWORD
|
||||
imap_host: imap.gmail.com
|
||||
mailbox: INBOX
|
||||
sender_contains: scholaralerts-noreply
|
||||
subject_contains: Google Scholar
|
||||
|
||||
ai:
|
||||
enabled: false
|
||||
provider: openai
|
||||
api_key_env: OPENAI_API_KEY
|
||||
model: gpt-5-mini
|
||||
max_papers_per_run: 30
|
||||
language: ko
|
||||
|
||||
joplin:
|
||||
enabled: true
|
||||
base_url: http://127.0.0.1:41184
|
||||
token_env: JOPLIN_TOKEN
|
||||
notebook_path: [Research, Radar Papers]
|
||||
note_mode: daily
|
||||
note_title_prefix: Radar Literature
|
||||
update_existing_note: true
|
||||
tags: [literature-monitor, automotive-radar]
|
||||
@@ -0,0 +1,90 @@
|
||||
app:
|
||||
timezone: Asia/Seoul
|
||||
lookback_days: 14
|
||||
max_papers_per_source_per_query: 20
|
||||
min_relevance: 2
|
||||
output_dir: ./data/outbox
|
||||
database_path: ./data/papers.db
|
||||
log_level: INFO
|
||||
|
||||
search:
|
||||
queries:
|
||||
- automotive radar
|
||||
- automotive imaging radar
|
||||
- 4D radar
|
||||
- FMCW radar MIMO
|
||||
- radar DOA estimation
|
||||
- wideband DOA radar
|
||||
- automotive radar antenna
|
||||
- radar target simulation
|
||||
- radar interference
|
||||
- super resolution DOA
|
||||
|
||||
categories:
|
||||
Antenna: [antenna, array, radome, beamforming, radiation pattern, sidelobe]
|
||||
Waveform: [waveform, FMCW, chirp, stepped frequency, frequency step, PMCW, phase coded]
|
||||
DOA: [DOA, direction of arrival, angle estimation, MUSIC, ESPRIT, angle FFT, beamspace, super resolution]
|
||||
MIMO: [MIMO, TDM, BPM, DDMA, virtual array, multiplexing]
|
||||
Target Modeling: [target model, target simulation, extended target, scatterer, scattering center, Swerling, RCS]
|
||||
Interference: [interference, mutual interference, interference mitigation, coexistence]
|
||||
RF Impairment: [phase noise, nonlinearity, IQ imbalance, leakage, oscillator]
|
||||
Imaging Radar: [imaging radar, 4D radar, high resolution radar, point cloud]
|
||||
|
||||
relevance_terms:
|
||||
automotive radar: 3
|
||||
imaging radar: 3
|
||||
4D radar: 3
|
||||
FMCW: 2
|
||||
MIMO: 2
|
||||
DOA: 2
|
||||
direction of arrival: 2
|
||||
antenna: 1
|
||||
target simulation: 2
|
||||
phase noise: 2
|
||||
interference: 1
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false # Set true after the IEEE developer application becomes Active.
|
||||
api_key_env: IEEE_API_KEY
|
||||
base_url: https://ieeexploreapi.ieee.org/api/v1/search/articles
|
||||
|
||||
semantic_scholar:
|
||||
enabled: false # Set true after your API key is approved/available.
|
||||
api_key_env: SEMANTIC_SCHOLAR_API_KEY
|
||||
base_url: https://api.semanticscholar.org/graph/v1/paper/search
|
||||
|
||||
crossref:
|
||||
enabled: true
|
||||
mailto_env: CROSSREF_MAILTO
|
||||
base_url: https://api.crossref.org/works
|
||||
|
||||
google_scholar_alert:
|
||||
enabled: false
|
||||
gmail_address_env: GMAIL_ADDRESS
|
||||
gmail_app_password_env: GMAIL_APP_PASSWORD
|
||||
imap_host: imap.gmail.com
|
||||
mailbox: INBOX
|
||||
sender_contains: scholaralerts-noreply
|
||||
subject_contains: Google Scholar
|
||||
|
||||
ai:
|
||||
enabled: true
|
||||
provider: gemini
|
||||
api_key_env: GEMINI_API_KEY
|
||||
model: gemini-3.6-flash
|
||||
max_papers_per_run: 20
|
||||
|
||||
# NAS mode writes to Joplin through Joplin Terminal, not the desktop Data API.
|
||||
joplin:
|
||||
enabled: false
|
||||
note_mode: daily
|
||||
note_title_prefix: Radar Literature
|
||||
|
||||
# OA status and PDF URL retrieval through Unpaywall API.
|
||||
oa:
|
||||
enabled: true
|
||||
provider: unpaywall
|
||||
email_env: UNPAYWALL_EMAIL
|
||||
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
app:
|
||||
timezone: Asia/Seoul
|
||||
lookback_days: 14
|
||||
max_papers_per_source_per_query: 50
|
||||
min_relevance: 2
|
||||
output_dir: ./data/outbox
|
||||
database_path: ./data/papers.db
|
||||
log_level: INFO
|
||||
|
||||
search:
|
||||
queries:
|
||||
- automotive radar
|
||||
- automotive imaging radar
|
||||
- 4D radar
|
||||
- FMCW radar MIMO
|
||||
- radar DOA estimation
|
||||
- wideband DOA radar
|
||||
- automotive radar antenna
|
||||
- radar target simulation
|
||||
- radar interference
|
||||
- radar phase noise
|
||||
categories:
|
||||
Antenna: [antenna, array, gap waveguide, gapwaveguide, radome, beamforming, radiation pattern, sidelobe]
|
||||
Waveform: [waveform, FMCW, chirp, stepped frequency, frequency step, PMCW, phase coded]
|
||||
DOA: [DOA, direction of arrival, angle estimation, MUSIC, ESPRIT, angle FFT, beamspace]
|
||||
MIMO: [MIMO, TDM, BPM, DDMA, virtual array, multiplexing]
|
||||
Target Modeling: [target model, target simulation, extended target, scatterer, scattering center, Swerling, RCS]
|
||||
Interference: [interference, mutual interference, interference mitigation, coexistence]
|
||||
RF Impairment: [phase noise, nonlinearity, IQ imbalance, leakage, oscillator]
|
||||
Imaging Radar: [imaging radar, 4D radar, high resolution radar, point cloud]
|
||||
relevance_terms:
|
||||
automotive radar: 3
|
||||
imaging radar: 3
|
||||
4D radar: 3
|
||||
FMCW: 2
|
||||
MIMO: 2
|
||||
DOA: 2
|
||||
direction of arrival: 2
|
||||
antenna: 1
|
||||
target simulation: 2
|
||||
phase noise: 2
|
||||
interference: 1
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false
|
||||
api_key_env: IEEE_API_KEY
|
||||
base_url: https://ieeexploreapi.ieee.org/api/v1/search/articles
|
||||
semantic_scholar:
|
||||
enabled: true
|
||||
api_key_env: SEMANTIC_SCHOLAR_API_KEY
|
||||
base_url: https://api.semanticscholar.org/graph/v1/paper/search
|
||||
crossref:
|
||||
enabled: false
|
||||
mailto_env: CROSSREF_MAILTO
|
||||
base_url: https://api.crossref.org/works
|
||||
google_scholar_alert:
|
||||
enabled: false
|
||||
gmail_address_env: GMAIL_ADDRESS
|
||||
gmail_app_password_env: GMAIL_APP_PASSWORD
|
||||
imap_host: imap.gmail.com
|
||||
mailbox: INBOX
|
||||
sender_contains: scholaralerts-noreply
|
||||
subject_contains: Google Scholar
|
||||
|
||||
ai:
|
||||
enabled: false
|
||||
provider: openai
|
||||
api_key_env: OPENAI_API_KEY
|
||||
model: gpt-5-mini
|
||||
max_papers_per_run: 30
|
||||
language: ko
|
||||
|
||||
joplin:
|
||||
enabled: true
|
||||
base_url: http://127.0.0.1:41184
|
||||
token_env: JOPLIN_TOKEN
|
||||
notebook_path: [Research, Radar Papers]
|
||||
note_mode: daily
|
||||
note_title_prefix: Radar Literature
|
||||
update_existing_note: true
|
||||
tags: [literature-monitor, automotive-radar]
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
paper-monitor-nas:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.nas
|
||||
image: paper-monitor-nas:latest
|
||||
container_name: paper-monitor-nas-run
|
||||
restart: "no"
|
||||
environment:
|
||||
TZ: Asia/Seoul
|
||||
|
||||
CIRCUIT_BREAKER_STATE: /state/provider_circuit_breaker_state.json
|
||||
|
||||
# Paper APIs
|
||||
IEEE_API_KEY: ${IEEE_API_KEY:-}
|
||||
SEMANTIC_SCHOLAR_API_KEY: ${SEMANTIC_SCHOLAR_API_KEY:-}
|
||||
CROSSREF_MAILTO: ${CROSSREF_MAILTO:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||
GROQ_API_KEY: ${GROQ_API_KEY}
|
||||
GMAIL_ADDRESS: ${GMAIL_ADDRESS:-}
|
||||
GMAIL_APP_PASSWORD: ${GMAIL_APP_PASSWORD:-}
|
||||
|
||||
# Existing Joplin WebDAV sync target
|
||||
JOPLIN_WEBDAV_URL: ${JOPLIN_WEBDAV_URL}
|
||||
JOPLIN_WEBDAV_USERNAME: ${JOPLIN_WEBDAV_USERNAME}
|
||||
JOPLIN_WEBDAV_PASSWORD: ${JOPLIN_WEBDAV_PASSWORD}
|
||||
JOPLIN_NOTEBOOK: ${JOPLIN_NOTEBOOK:-Radar Papers}
|
||||
JOPLIN_WRITE_ENABLED: ${JOPLIN_WRITE_ENABLED:-false}
|
||||
JOPLIN_IGNORE_TLS_ERRORS: ${JOPLIN_IGNORE_TLS_ERRORS:-false}
|
||||
|
||||
# Unpaywall E-Mail address for OA status and PDF URL retrieval.
|
||||
# This is required for the Unpaywall API.
|
||||
UNPAYWALL_EMAIL: ${UNPAYWALL_EMAIL}
|
||||
|
||||
volumes:
|
||||
- ./config.nas.yaml:/app/config.yaml:ro
|
||||
- ./data:/app/data
|
||||
- ./joplin-profile:/joplin-profile
|
||||
- ./state:/state
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
paper-monitor:
|
||||
build: .
|
||||
container_name: paper-monitor
|
||||
restart: "no"
|
||||
environment:
|
||||
- IEEE_API_KEY=${IEEE_API_KEY}
|
||||
- SEMANTIC_SCHOLAR_API_KEY=${SEMANTIC_SCHOLAR_API_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- CROSSREF_MAILTO=${CROSSREF_MAILTO}
|
||||
- GMAIL_ADDRESS=${GMAIL_ADDRESS}
|
||||
- GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD}
|
||||
- JOPLIN_TOKEN=${JOPLIN_TOKEN}
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./data:/app/data
|
||||
command: ["python", "-m", "paper_monitor", "--config", "/app/config.yaml"]
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
PROFILE="/joplin-profile"
|
||||
CONFIG="/app/config.yaml"
|
||||
RESULT_JSON="/app/data/last_result.json"
|
||||
|
||||
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||
fail() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
: "${JOPLIN_WEBDAV_URL:?JOPLIN_WEBDAV_URL is required}"
|
||||
: "${JOPLIN_WEBDAV_USERNAME:?JOPLIN_WEBDAV_USERNAME is required}"
|
||||
: "${JOPLIN_WEBDAV_PASSWORD:?JOPLIN_WEBDAV_PASSWORD is required}"
|
||||
|
||||
JOPLIN_NOTEBOOK="${JOPLIN_NOTEBOOK:-Radar Papers}"
|
||||
JOPLIN_WRITE_ENABLED="${JOPLIN_WRITE_ENABLED:-false}"
|
||||
JOPLIN_IGNORE_TLS_ERRORS="${JOPLIN_IGNORE_TLS_ERRORS:-false}"
|
||||
|
||||
mkdir -p "$PROFILE" /app/data/outbox
|
||||
|
||||
log "Configuring Joplin CLI WebDAV profile..."
|
||||
joplin --profile "$PROFILE" config sync.target 6 >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.6.path "$JOPLIN_WEBDAV_URL" >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.6.username "$JOPLIN_WEBDAV_USERNAME" >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.6.password "$JOPLIN_WEBDAV_PASSWORD" >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.wipeOutFailSafe true >/dev/null
|
||||
|
||||
if [[ "${JOPLIN_IGNORE_TLS_ERRORS,,}" == "true" ]]; then
|
||||
joplin --profile "$PROFILE" config net.ignoreTlsErrors true >/dev/null
|
||||
else
|
||||
joplin --profile "$PROFILE" config net.ignoreTlsErrors false >/dev/null
|
||||
fi
|
||||
|
||||
# Pull the current remote state first. This is important when PC/mobile also use the same WebDAV target.
|
||||
log "Synchronising Joplin from WebDAV before writing..."
|
||||
joplin --profile "$PROFILE" sync
|
||||
|
||||
if [[ "${JOPLIN_WRITE_ENABLED,,}" != "true" ]]; then
|
||||
log "JOPLIN_WRITE_ENABLED=false: sync-only safety mode. No paper note will be written."
|
||||
log "If this first sync succeeded, set JOPLIN_WRITE_ENABLED=true and run again."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Running paper monitor..."
|
||||
python -m paper_monitor --config "$CONFIG" --dry-run
|
||||
[[ -f "$RESULT_JSON" ]] || fail "Result JSON not produced: $RESULT_JSON"
|
||||
|
||||
readarray -t META < <(python - "$RESULT_JSON" <<'PY'
|
||||
import json, sys
|
||||
p=sys.argv[1]
|
||||
d=json.load(open(p,encoding='utf-8'))
|
||||
print(d.get('report_count',0))
|
||||
print(d.get('report_title',''))
|
||||
print(d.get('markdown',''))
|
||||
PY
|
||||
)
|
||||
|
||||
REPORT_COUNT="${META[0]:-0}"
|
||||
REPORT_TITLE="${META[1]:-}"
|
||||
MARKDOWN="${META[2]:-}"
|
||||
|
||||
if [[ "$REPORT_COUNT" == "0" ]]; then
|
||||
log "No papers in today's report. Nothing to write to Joplin."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -n "$REPORT_TITLE" ]] || fail "Empty report title"
|
||||
[[ -f "$MARKDOWN" ]] || fail "Markdown report not found: $MARKDOWN"
|
||||
|
||||
log "Ensuring notebook exists: $JOPLIN_NOTEBOOK"
|
||||
if ! joplin --profile "$PROFILE" use "$JOPLIN_NOTEBOOK" >/dev/null 2>&1; then
|
||||
joplin --profile "$PROFILE" mkbook "$JOPLIN_NOTEBOOK"
|
||||
fi
|
||||
|
||||
# Replace only the daily report with the exact same title. Since the report is cumulative
|
||||
# for the local day, rerunning is idempotent and will not lose earlier papers from the same day.
|
||||
log "Replacing existing daily note if present: $REPORT_TITLE"
|
||||
joplin --profile "$PROFILE" rmnote "$REPORT_TITLE" -f >/dev/null 2>&1 || true
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
TMP_MD="$TMP_DIR/$REPORT_TITLE.md"
|
||||
cp "$MARKDOWN" "$TMP_MD"
|
||||
|
||||
log "Importing report into Joplin notebook: $JOPLIN_NOTEBOOK"
|
||||
joplin --profile "$PROFILE" import "$TMP_MD" "$JOPLIN_NOTEBOOK" --format md -f
|
||||
|
||||
log "Synchronising Joplin changes to WebDAV..."
|
||||
joplin --profile "$PROFILE" sync
|
||||
|
||||
log "Completed: $REPORT_TITLE ($REPORT_COUNT papers)"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
PROFILE="/joplin-profile"
|
||||
: "${JOPLIN_WEBDAV_URL:?JOPLIN_WEBDAV_URL is required}"
|
||||
: "${JOPLIN_WEBDAV_USERNAME:?JOPLIN_WEBDAV_USERNAME is required}"
|
||||
: "${JOPLIN_WEBDAV_PASSWORD:?JOPLIN_WEBDAV_PASSWORD is required}"
|
||||
mkdir -p "$PROFILE"
|
||||
joplin --profile "$PROFILE" config sync.target 6 >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.6.path "$JOPLIN_WEBDAV_URL" >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.6.username "$JOPLIN_WEBDAV_USERNAME" >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.6.password "$JOPLIN_WEBDAV_PASSWORD" >/dev/null
|
||||
joplin --profile "$PROFILE" config sync.wipeOutFailSafe true >/dev/null
|
||||
joplin --profile "$PROFILE" sync
|
||||
joplin --profile "$PROFILE" status
|
||||
@@ -0,0 +1,556 @@
|
||||
# Paper Monitor 프로젝트 - 남은 작업 및 검토 TODO
|
||||
|
||||
최종 업데이트: 2026-08-15
|
||||
|
||||
이 문서는 지금까지 구축한 Synology NAS 기반 논문 자동 모니터링 시스템에서 **아직 미정이거나 수행하지 못한 항목**, 그리고 **운영 전에 변경 또는 추가 검토가 필요한 항목**을 정리한 TODO 목록이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 즉시 처리 권장
|
||||
|
||||
### [ ] Semantic Scholar 차단 해제 후 재활성화
|
||||
- 현재 상태: 접근 제한/차단 상태이므로 `enabled: false` 권장
|
||||
- 차단이 풀린 뒤 다시 활성화
|
||||
- 재활성화 후 확인
|
||||
- 2.5초 이상 요청 간격이 실제 적용되는지
|
||||
- circuit breaker가 429 지속 시 정상 동작하는지
|
||||
- 한 run에서 과도한 재시도가 발생하지 않는지
|
||||
- 정상화 후에도 429가 잦으면 검토
|
||||
- 요청 간격 3~5초로 증가
|
||||
- 검색 query 수 축소
|
||||
- `max_retries` 추가 축소
|
||||
|
||||
### [ ] IEEE 접근 차단 해제 여부 확인
|
||||
- 현재 상태
|
||||
- 집 인터넷: `developer.ieee.org` -> HTTP 403
|
||||
- 동일 PC + 휴대폰 테더링: HTTP 200 OK
|
||||
- 현재 공인 IP 또는 해당 회선이 IEEE/Mashery/WAF에서 제한된 가능성이 매우 높음
|
||||
- 현재는 `IEEE enabled: false` 유지
|
||||
- 차단 해제 후에만 다시 활성화
|
||||
- 재활성화 시 먼저 1개 query만 수동 테스트
|
||||
- 401 / 403 / 429 / timeout / connection error 발생 시 해당 run의 IEEE 전체 중단 유지
|
||||
- 동일 run에서 IEEE 자동 retry는 하지 않음
|
||||
- 요청 간 최소 간격 5초 유지
|
||||
|
||||
### [ ] IEEE API 승인 상태 확인
|
||||
- 네트워크 차단 문제와 API 승인 문제는 별개
|
||||
- IEEE API key가 최종 승인되었는지 확인 후 활성화
|
||||
- 웹사이트 접속 정상화와 API 승인 상태를 각각 확인
|
||||
|
||||
### [ ] 노출된 IEEE API key 교체 여부 확인
|
||||
- 과거 오류 로그에 API key가 query string 형태로 노출된 적이 있음
|
||||
- 아직 교체하지 않았다면 새 API key 발급/회전 권장
|
||||
- 현재 수정한 IEEE collector는 향후 오류 로그에서 key가 노출되지 않도록 유지
|
||||
|
||||
---
|
||||
|
||||
## 2. 스케줄러 / 운영 설정
|
||||
|
||||
현재 미완료 항목 없음. 완료된 내용은 문서 하단 `완료 항목` 섹션 참조.
|
||||
|
||||
---
|
||||
|
||||
## 3. 검색 범위 및 수집 정책
|
||||
|
||||
### [ ] `lookback_days: 14` 유지 여부 장기 검토
|
||||
현재:
|
||||
```yaml
|
||||
lookback_days: 14
|
||||
```
|
||||
|
||||
주 1회 실행에는 안전한 값이며 현재는 유지 권장.
|
||||
|
||||
향후 확인:
|
||||
- 매주 중복 수집량이 지나치게 많으면 10일 정도로 감소 검토
|
||||
- publication date가 늦게 반영되는 source가 많으면 14일 유지
|
||||
|
||||
### [ ] Query 목록 재정비
|
||||
현재 자동차 레이더 관련 다수 query 사용.
|
||||
|
||||
검토 필요:
|
||||
- 중복도가 지나치게 높은 query 제거
|
||||
- 실제 유용 논문을 거의 만들지 않는 query 제거
|
||||
- 주요 관심영역별 query 분리
|
||||
|
||||
예:
|
||||
- Automotive radar
|
||||
- Imaging radar / 4D radar
|
||||
- FMCW / Waveform
|
||||
- MIMO / DDMA
|
||||
- DOA / Wideband DOA / Super-resolution
|
||||
- Antenna
|
||||
- Target modeling / Scattering center
|
||||
- Interference / RF impairment
|
||||
|
||||
### [ ] Source별 query 목록 분리 검토
|
||||
IEEE, Semantic Scholar, Crossref에서 동일한 query 세트를 사용하는 대신 source 특성에 맞게 별도 query 목록을 둘지 검토.
|
||||
|
||||
예:
|
||||
```yaml
|
||||
sources:
|
||||
crossref:
|
||||
queries: [...]
|
||||
semantic_scholar:
|
||||
queries: [...]
|
||||
ieee:
|
||||
queries: [...]
|
||||
```
|
||||
|
||||
장점:
|
||||
- API 호출량 감소
|
||||
- 불필요한 중복 감소
|
||||
- IEEE와 Semantic Scholar 차단 가능성 감소
|
||||
|
||||
---
|
||||
|
||||
## 4. Gemini AI 운영 설정
|
||||
|
||||
### [ ] Gemini 무료 quota 실제 사용량 모니터링
|
||||
확인 항목:
|
||||
- full-text 요청 수
|
||||
- abstract-only 요청 수
|
||||
- 한 run에서 token 사용량
|
||||
- quota 초과/429 발생 여부
|
||||
|
||||
필요 시:
|
||||
- full-text 분석 대상 relevance threshold 추가
|
||||
- 예: rule-based 또는 abstract AI relevance가 높은 논문만 full-text 분석
|
||||
|
||||
### [ ] Full-text AI 2단계 분석 구조 검토
|
||||
현재는 OA PDF가 있으면 바로 full-text 분석.
|
||||
|
||||
향후 비용 절감 구조 검토:
|
||||
1. 모든 신규 논문 -> title + abstract 분석
|
||||
2. relevance >= 특정 값인 논문만 OA PDF full-text 분석
|
||||
|
||||
예:
|
||||
```text
|
||||
abstract AI relevance >= 4/5
|
||||
-> full-text AI
|
||||
else
|
||||
-> abstract 결과만 유지
|
||||
```
|
||||
|
||||
장점:
|
||||
- Gemini 호출량/토큰 절감
|
||||
- PDF 다운로드/파싱 횟수 감소
|
||||
|
||||
### [ ] Gemini 모델명 config 기반 유지
|
||||
현재 `gemini-3.6-flash` 사용.
|
||||
|
||||
향후 모델 변경 가능성을 고려해 hard-code 최소화.
|
||||
`config.nas.yaml` 값만 바꾸면 동작하도록 유지.
|
||||
|
||||
### [ ] Gemini AFC 경고 정리
|
||||
현재 정상 동작하지만 아래 경고가 발생했음:
|
||||
|
||||
```text
|
||||
Direct use of automatic function calling (AFC) in Models.generate_content is not recommended...
|
||||
```
|
||||
|
||||
기능에는 문제가 없으나 향후 Google 권장 API 방식으로 migration 검토.
|
||||
|
||||
---
|
||||
|
||||
## 5. Groq fallback
|
||||
|
||||
### [ ] Groq API 인증 문제 해결
|
||||
현재 상태:
|
||||
- Groq API key를 등록했으나 401 `Invalid API Key`
|
||||
- 새 key 교체 후에도 인증 문제로 일단 보류
|
||||
|
||||
향후:
|
||||
- Groq Console에서 project/key 상태 재확인
|
||||
- `models.list()` 인증 테스트
|
||||
- 인증 성공 후 Gemini fallback provider로 연결
|
||||
|
||||
목표:
|
||||
```text
|
||||
Gemini 성공
|
||||
-> 완료
|
||||
|
||||
Gemini quota/timeout/API 오류
|
||||
-> Groq fallback
|
||||
|
||||
Groq도 실패
|
||||
-> AI 없이 논문 저장
|
||||
```
|
||||
|
||||
### [ ] Groq dependency는 인증 성공 후 정식 추가
|
||||
현재 Gemini는 정식 dependency 추가 완료.
|
||||
Groq는 인증 정상화 후 `pyproject.toml`에 추가.
|
||||
|
||||
---
|
||||
|
||||
## 6. OA / PDF 처리
|
||||
|
||||
### [ ] Semantic Scholar `openAccessPdf` 우선 사용 검증
|
||||
계획:
|
||||
1. Semantic Scholar `openAccessPdf`
|
||||
2. 없으면 Unpaywall
|
||||
3. 없으면 abstract fallback
|
||||
|
||||
Semantic Scholar가 다시 활성화된 뒤 실제 논문에서 `pdf_url`이 정상 채워지는지 확인 필요.
|
||||
|
||||
### [ ] Unpaywall 실제 신규 논문 경로 검증
|
||||
standalone 테스트는 성공했지만 실제 신규 논문 run에서 다음 로그를 아직 충분히 확인하지 못함:
|
||||
|
||||
```text
|
||||
Unpaywall candidates: N / M new papers
|
||||
Unpaywall resolved: status=... pdf=YES ...
|
||||
```
|
||||
|
||||
신규 논문 발생 시 확인.
|
||||
|
||||
### [ ] PDF 다운로드 실패 유형 통계/로그 개선
|
||||
현재 예외처리는 되어 있음.
|
||||
|
||||
향후 구분 로그 검토:
|
||||
- 403
|
||||
- 404
|
||||
- timeout
|
||||
- HTML 응답
|
||||
- invalid PDF signature
|
||||
- max file size 초과
|
||||
- parse failure
|
||||
- no extractable text
|
||||
|
||||
### [ ] 스캔 PDF OCR 지원 여부
|
||||
현재 `pypdf` 기반 텍스트 추출.
|
||||
스캔 이미지 PDF는 `No extractable text`가 될 수 있음.
|
||||
|
||||
현재 권장:
|
||||
- OCR은 우선 미지원
|
||||
- abstract fallback
|
||||
|
||||
필요성이 커질 경우에만 OCR 기능 추가 검토.
|
||||
|
||||
### [ ] PDF text 선택적 추출 개선
|
||||
현재 제한:
|
||||
- 최대 40 pages
|
||||
- 최대 약 120,000 chars
|
||||
- 최대 PDF 25 MB
|
||||
|
||||
향후 개선 검토:
|
||||
- Abstract
|
||||
- Introduction
|
||||
- Method
|
||||
- Results
|
||||
- Conclusion
|
||||
|
||||
섹션을 우선적으로 추출하여 Gemini에 전달.
|
||||
|
||||
---
|
||||
|
||||
## 7. AI 상태 / retry 정책
|
||||
|
||||
### [ ] 실제 운영 데이터에서 `failed -> retry -> done` 경로 확인
|
||||
임시 DB E2E 테스트는 성공함.
|
||||
|
||||
실제 운영에서 최초 Gemini/API 오류가 발생했을 때:
|
||||
```text
|
||||
ai_status = failed
|
||||
```
|
||||
저장 후 다음 주 실행에서:
|
||||
```text
|
||||
retry -> done
|
||||
```
|
||||
되는지 로그 확인.
|
||||
|
||||
### [ ] `skipped` 논문 재평가 정책 결정
|
||||
현재:
|
||||
```text
|
||||
PDF 없음 + abstract 없음
|
||||
-> ai_status = skipped
|
||||
-> 자동 재시도 안 함
|
||||
```
|
||||
|
||||
문제:
|
||||
- 나중에 Semantic Scholar에서 abstract가 추가되거나
|
||||
- Unpaywall에서 OA PDF가 생길 수 있음
|
||||
|
||||
향후 정책 후보:
|
||||
- skipped 논문을 30일 후 1회 재조회
|
||||
- metadata가 개선된 경우에만 재분석
|
||||
- 계속 skipped인 논문은 영구 종료
|
||||
|
||||
### [ ] AI retry 횟수 제한 추가 검토
|
||||
현재 `failed`는 이후 run에서 다시 시도 가능.
|
||||
|
||||
향후 DB 필드 추가 검토:
|
||||
```text
|
||||
ai_retry_count
|
||||
ai_last_attempt_at
|
||||
```
|
||||
|
||||
예:
|
||||
- 최대 3회 실패 후 `permanent_failed`
|
||||
- 무한 재시도 방지
|
||||
|
||||
---
|
||||
|
||||
## 8. DB schema / 데이터 관리
|
||||
|
||||
### [ ] AI provenance 정보 추가 검토
|
||||
현재 저장:
|
||||
- relevance
|
||||
- categories
|
||||
- summary
|
||||
- ai_reason
|
||||
- oa_status
|
||||
- pdf_url
|
||||
- ai_analysis_level
|
||||
- ai_status
|
||||
|
||||
추가 검토:
|
||||
```text
|
||||
ai_provider
|
||||
ai_model
|
||||
ai_processed_at
|
||||
ai_retry_count
|
||||
```
|
||||
|
||||
장점:
|
||||
- 어떤 모델이 만든 요약인지 추적 가능
|
||||
- 모델 변경 전/후 비교 가능
|
||||
|
||||
### [ ] DB backup 정책
|
||||
`papers.db`는 시스템의 핵심 상태 데이터.
|
||||
|
||||
권장:
|
||||
- Synology Hyper Backup 또는 별도 주기 backup
|
||||
- 최소 주 1회
|
||||
- `papers.db` + config + source code 함께 backup 검토
|
||||
|
||||
### [ ] 오래된 테스트 데이터 확인
|
||||
임시 DB는 `/tmp`에 생성했으므로 container 종료 시 영향 없음.
|
||||
운영 `papers.db`에 테스트 논문이 섞여 있지 않은지 한 번 확인 권장.
|
||||
|
||||
---
|
||||
|
||||
## 9. Joplin 출력 개선
|
||||
|
||||
### [ ] 실제 AI 분석된 신규 논문이 Joplin에 출력되는 것 확인
|
||||
render standalone 테스트는 성공.
|
||||
|
||||
실제 신규 논문에서 확인할 항목:
|
||||
```text
|
||||
AI Analysis: Full text
|
||||
또는
|
||||
AI Analysis: Abstract only
|
||||
|
||||
AI Summary
|
||||
Why relevant
|
||||
OA Status
|
||||
OA PDF
|
||||
```
|
||||
|
||||
### [ ] Joplin note layout 개선 검토
|
||||
현재:
|
||||
1. New papers 표
|
||||
2. Summaries 상세 항목
|
||||
|
||||
향후 가능:
|
||||
- relevance별 section
|
||||
- category별 section
|
||||
- AI Full-text 분석 논문 상단 배치
|
||||
- Top 5 paper 별도 section
|
||||
|
||||
### [ ] 같은 날 재실행 시 note replacement 정책 확인
|
||||
현재 같은 날짜 note를 교체하면서 해당 날짜 발견 논문을 누적하는 구조.
|
||||
|
||||
주 1회 운영에서는 큰 문제 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. Source 추가
|
||||
|
||||
### [ ] Google Scholar Alert 연동
|
||||
아직 보류 중.
|
||||
|
||||
계획:
|
||||
```text
|
||||
Google Scholar Alert
|
||||
-> Gmail
|
||||
-> IMAP
|
||||
-> ScholarGmailCollector
|
||||
-> paper-monitor
|
||||
```
|
||||
|
||||
웹 scraping 대신 Google Scholar Alert 메일을 사용하는 방식 권장.
|
||||
|
||||
필요:
|
||||
- Google Scholar Alert 생성
|
||||
- Gmail App Password
|
||||
- NAS `.env`
|
||||
- Gmail collector 활성화
|
||||
- sender/subject filter 테스트
|
||||
|
||||
### [ ] IEEE 복구 후 source 우선순위 재정의
|
||||
장기 목표 예:
|
||||
```text
|
||||
IEEE Xplore
|
||||
Semantic Scholar
|
||||
Crossref
|
||||
Google Scholar Alert
|
||||
```
|
||||
|
||||
각 source의 역할 중복을 보고 일부 source를 보조용으로 낮출지 검토.
|
||||
|
||||
---
|
||||
|
||||
## 11. 보안
|
||||
|
||||
### [ ] `.env` 권한 확인
|
||||
API key, Gmail password 등이 들어있으므로 NAS에서 접근 권한 최소화.
|
||||
|
||||
예:
|
||||
```sh
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
단, Docker/실행 사용자가 읽을 수 있는지 확인 후 적용.
|
||||
|
||||
### [ ] 로그에 secret 노출 여부 재점검
|
||||
특히 확인:
|
||||
- IEEE API key
|
||||
- Gemini API key
|
||||
- Semantic Scholar API key
|
||||
- Gmail App Password
|
||||
|
||||
로그에는 key value를 출력하지 않도록 유지.
|
||||
|
||||
### [ ] API key rotation 정책
|
||||
키가 채팅/로그에 노출되었다면 즉시 교체.
|
||||
장기적으로 필요 시 주기적 rotation 검토.
|
||||
|
||||
---
|
||||
|
||||
## 12. Docker / 유지보수
|
||||
|
||||
### [ ] dependency 버전 pinning 검토
|
||||
현재 패키지 버전을 느슨하게 두면 향후 rebuild 시 API가 깨질 수 있음.
|
||||
|
||||
특히:
|
||||
- `google-genai`
|
||||
- `pypdf`
|
||||
- `requests`
|
||||
- `PyYAML`
|
||||
|
||||
운영 안정화 후 known-good version으로 pinning 검토.
|
||||
|
||||
### [ ] Docker image rebuild 정책
|
||||
기억할 규칙:
|
||||
|
||||
```text
|
||||
config.nas.yaml 변경
|
||||
-> rebuild 불필요
|
||||
|
||||
.env 변경
|
||||
-> rebuild 불필요
|
||||
|
||||
Python source 변경
|
||||
-> rebuild 필요
|
||||
|
||||
pyproject.toml 변경
|
||||
-> rebuild 필요
|
||||
```
|
||||
|
||||
### [ ] 테스트 파일 유지 여부
|
||||
현재 생성한 테스트 모듈:
|
||||
- `test_e2e_ai.py`
|
||||
- `test_retry_ai.py`
|
||||
|
||||
향후:
|
||||
- 유지해서 regression test로 사용할지
|
||||
- 별도 `tests/` 폴더로 이동할지 결정
|
||||
|
||||
---
|
||||
|
||||
# 현재 운영 권장 설정
|
||||
|
||||
현재 접근 제한 상황을 고려하면 당분간 다음 구성이 안전하다.
|
||||
|
||||
```yaml
|
||||
app:
|
||||
timezone: Asia/Seoul
|
||||
lookback_days: 14
|
||||
max_papers_per_source_per_query: 20
|
||||
min_relevance: 2
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false
|
||||
|
||||
semantic_scholar:
|
||||
enabled: false
|
||||
|
||||
ai:
|
||||
enabled: true
|
||||
provider: gemini
|
||||
api_key_env: GEMINI_API_KEY
|
||||
model: gemini-3.6-flash
|
||||
max_papers_per_run: 20
|
||||
|
||||
oa:
|
||||
enabled: true
|
||||
provider: unpaywall
|
||||
email_env: UNPAYWALL_EMAIL
|
||||
```
|
||||
|
||||
스케줄:
|
||||
|
||||
```text
|
||||
매주 월요일 09:00 KST
|
||||
lookback: 14일
|
||||
```
|
||||
|
||||
현재 IEEE와 Semantic Scholar가 비활성화되어 있으므로 당분간 Crossref 중심으로 수집되고, DOI가 있는 신규 논문에 대해 Unpaywall OA 검색 및 Gemini 분석이 수행된다.
|
||||
|
||||
---
|
||||
|
||||
# 우선순위 요약
|
||||
|
||||
## P0 - 운영 전에 확인
|
||||
- [ ] Semantic Scholar `enabled: false`
|
||||
- [ ] IEEE `enabled: false`
|
||||
- [ ] `.env` secret 권한 및 IEEE key rotation 여부 확인
|
||||
|
||||
## P1 - 접근 복구 후
|
||||
- [ ] Semantic Scholar 재활성화 + 429 상태 확인
|
||||
- [ ] IEEE 웹사이트 403 해제 확인
|
||||
- [ ] IEEE API 승인 상태 확인
|
||||
- [ ] IEEE 1-query 테스트 후 재활성화
|
||||
|
||||
## P2 - 기능 보강
|
||||
- [ ] Google Scholar Alert + Gmail collector
|
||||
- [ ] Groq 인증 해결 + Gemini fallback
|
||||
- [ ] `skipped` 재평가 정책
|
||||
- [ ] AI retry count / provider / model / processed_at DB 저장
|
||||
- [ ] PDF section-aware extraction
|
||||
|
||||
## P3 - 장기 운영
|
||||
- [ ] DB backup
|
||||
- [ ] scheduler timestamp 로그 보관기간/정리 정책(예: 8주)
|
||||
- [ ] dependency version pinning
|
||||
- [ ] query/source 최적화
|
||||
- [ ] Joplin report layout 개선
|
||||
|
||||
---
|
||||
|
||||
# 완료 항목 (2026-08-15 기준)
|
||||
|
||||
## 스케줄러 / 운영
|
||||
- [x] DSM Task Scheduler 최종 등록 확인
|
||||
- [x] DSM 월요일 09:00 scheduler 최종 등록/활성화
|
||||
- [x] DSM에서 등록한 작업 수동 1회 실행 및 `RESULT=0` 확인
|
||||
- [x] Joplin import / WebDAV sync 정상 동작 확인
|
||||
- [x] Scheduler 로그를 실행별 개별 파일로 생성하도록 적용
|
||||
|
||||
적용 로그 파일명 형식:
|
||||
```text
|
||||
scheduler-YYYY-MM-DD_HH-MM-SS.log
|
||||
```
|
||||
|
||||
## Gemini 운영값
|
||||
- [x] `max_papers_per_run` 운영값 확정: `20`
|
||||
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "paper-monitor"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"requests>=2.32,<3",
|
||||
"PyYAML>=6.0,<7",
|
||||
"python-dateutil>=2.9,<3",
|
||||
"beautifulsoup4>=4.12,<5",
|
||||
"openai>=1.0",
|
||||
"tzdata",
|
||||
"google-genai",
|
||||
"pypdf",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
@@ -0,0 +1,7 @@
|
||||
requests>=2.32,<3
|
||||
PyYAML>=6.0,<7
|
||||
python-dateutil>=2.9,<3
|
||||
beautifulsoup4>=4.12,<5
|
||||
openai>=1.0
|
||||
|
||||
tzdata
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
PROJECT_DIR="/volume1/docker/paper-monitor"
|
||||
LOG_DIR="$PROJECT_DIR/data/logs"
|
||||
LOG_STAMP="$(date '+%Y-%m-%d_%H-%M-%S')"
|
||||
LOG_FILE="$LOG_DIR/scheduler-${LOG_STAMP}.log"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
echo "============================================================" >> "$LOG_FILE"
|
||||
echo "START $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE"
|
||||
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
/usr/local/bin/docker compose \
|
||||
-f docker-compose.nas.yml \
|
||||
run --rm paper-monitor-nas \
|
||||
>> "$LOG_FILE" 2>&1
|
||||
|
||||
RESULT=$?
|
||||
|
||||
echo "END $(date '+%Y-%m-%d %H:%M:%S') RESULT=$RESULT" >> "$LOG_FILE"
|
||||
echo "============================================================" >> "$LOG_FILE"
|
||||
|
||||
exit "$RESULT"
|
||||
@@ -0,0 +1 @@
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .runner import cli
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
|
||||
|
||||
SYSTEM = '''You are a technical literature triage assistant for an automotive radar engineer.
|
||||
|
||||
Analyze ONLY the supplied metadata.
|
||||
Do not invent experimental results, methods, conclusions, or claims that are not supported by the supplied metadata.
|
||||
|
||||
Return:
|
||||
- relevance: integer from 1 to 5
|
||||
- categories: technical categories
|
||||
- summary: 1-2 concise Korean sentences
|
||||
- reason: concise Korean explanation of why this paper is relevant
|
||||
|
||||
Preferred categories:
|
||||
Antenna,
|
||||
Waveform,
|
||||
DOA,
|
||||
MIMO,
|
||||
Target Modeling,
|
||||
Interference,
|
||||
RF Impairment,
|
||||
Imaging Radar,
|
||||
Signal Processing,
|
||||
Hardware,
|
||||
Other.
|
||||
'''
|
||||
|
||||
|
||||
AI_RESPONSE_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relevance": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 5,
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"relevance",
|
||||
"categories",
|
||||
"summary",
|
||||
"reason",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _make_payload(paper):
|
||||
return {
|
||||
"title": paper.title,
|
||||
"abstract": paper.abstract,
|
||||
"venue": paper.venue,
|
||||
"year": paper.year,
|
||||
"current_categories": paper.categories,
|
||||
"current_relevance": paper.relevance,
|
||||
}
|
||||
|
||||
|
||||
def _apply_result(paper, data):
|
||||
paper.relevance = max(
|
||||
1,
|
||||
min(
|
||||
5,
|
||||
int(data.get("relevance", paper.relevance))
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(data.get("categories"), list) and data["categories"]:
|
||||
paper.categories = [
|
||||
str(x)
|
||||
for x in data["categories"][:5]
|
||||
]
|
||||
|
||||
paper.summary = str(
|
||||
data.get("summary", "")
|
||||
).strip()
|
||||
|
||||
paper.ai_reason = str(
|
||||
data.get("reason", "")
|
||||
).strip()
|
||||
|
||||
return paper
|
||||
|
||||
|
||||
def enrich_with_gemini(paper, api_key, model):
|
||||
if not api_key:
|
||||
return paper
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
payload = _make_payload(paper)
|
||||
|
||||
prompt = (
|
||||
SYSTEM
|
||||
+ "\n\nPaper metadata:\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)
|
||||
)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_schema=AI_RESPONSE_SCHEMA,
|
||||
),
|
||||
)
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
paper = _apply_result(paper, data)
|
||||
paper.ai_analysis_level = "abstract"
|
||||
|
||||
return paper
|
||||
|
||||
def enrich_with_gemini_full_text(
|
||||
paper,
|
||||
full_text,
|
||||
api_key,
|
||||
model,
|
||||
):
|
||||
if not api_key:
|
||||
return paper
|
||||
|
||||
if not full_text or not full_text.strip():
|
||||
return paper
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
payload = {
|
||||
"title": paper.title,
|
||||
"abstract": paper.abstract,
|
||||
"venue": paper.venue,
|
||||
"year": paper.year,
|
||||
"full_text": full_text,
|
||||
"current_categories": paper.categories,
|
||||
"current_relevance": paper.relevance,
|
||||
}
|
||||
|
||||
prompt = (
|
||||
SYSTEM
|
||||
+ """
|
||||
|
||||
You are given extracted full text from the paper.
|
||||
|
||||
Base the analysis primarily on the supplied full text.
|
||||
Do not invent information that is not present in the supplied text.
|
||||
If the extracted text appears incomplete or corrupted, be conservative.
|
||||
|
||||
The summary should describe the paper's main technical approach,
|
||||
contribution, and relevance to automotive radar when supported by the text.
|
||||
"""
|
||||
+ "\n\nPaper data:\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)
|
||||
)
|
||||
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_schema=AI_RESPONSE_SCHEMA,
|
||||
),
|
||||
)
|
||||
|
||||
data = json.loads(response.text)
|
||||
|
||||
paper = _apply_result(paper, data)
|
||||
paper.ai_analysis_level = "full_text"
|
||||
|
||||
return paper
|
||||
|
||||
def enrich_with_openai(paper, api_key, model):
|
||||
if not api_key:
|
||||
return paper
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
payload = _make_payload(paper)
|
||||
|
||||
response = client.responses.create(
|
||||
model=model,
|
||||
instructions=SYSTEM,
|
||||
input=json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False
|
||||
),
|
||||
)
|
||||
|
||||
text = response.output_text.strip()
|
||||
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
return _apply_result(paper, data)
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderPolicy:
|
||||
# 동일 오류가 반복될 때 적용할 cooldown 시간(초)
|
||||
status_cooldowns: dict[int, tuple[int, ...]]
|
||||
network_cooldowns: tuple[int, ...] | None = None
|
||||
|
||||
|
||||
PROVIDER_POLICIES: dict[str, ProviderPolicy] = {
|
||||
"ieee": ProviderPolicy(
|
||||
status_cooldowns={
|
||||
# 1회 403: 30분
|
||||
# 2회 403: 6시간
|
||||
# 3회 이상: 24시간
|
||||
403: (30 * 60, 6 * 60 * 60, 24 * 60 * 60),
|
||||
|
||||
# 혹시 rate limit이 발생할 경우
|
||||
429: (2 * 60, 10 * 60, 60 * 60),
|
||||
|
||||
# 서버 장애
|
||||
500: (60, 5 * 60, 30 * 60),
|
||||
502: (60, 5 * 60, 30 * 60),
|
||||
503: (60, 5 * 60, 30 * 60),
|
||||
504: (60, 5 * 60, 30 * 60),
|
||||
},
|
||||
network_cooldowns=(60, 5 * 60, 30 * 60),
|
||||
),
|
||||
|
||||
"semantic_scholar": ProviderPolicy(
|
||||
status_cooldowns={
|
||||
# CloudFront / WAF 차단
|
||||
403: (30 * 60, 6 * 60 * 60, 24 * 60 * 60),
|
||||
|
||||
# API rate limit
|
||||
429: (2 * 60, 10 * 60, 60 * 60),
|
||||
|
||||
500: (60, 5 * 60, 30 * 60),
|
||||
502: (60, 5 * 60, 30 * 60),
|
||||
503: (60, 5 * 60, 30 * 60),
|
||||
504: (60, 5 * 60, 30 * 60),
|
||||
},
|
||||
network_cooldowns=(60, 5 * 60, 30 * 60),
|
||||
),
|
||||
|
||||
"semanticscholar": ProviderPolicy(
|
||||
status_cooldowns={
|
||||
403: (30 * 60, 6 * 60 * 60, 24 * 60 * 60),
|
||||
429: (2 * 60, 10 * 60, 60 * 60),
|
||||
500: (60, 5 * 60, 30 * 60),
|
||||
502: (60, 5 * 60, 30 * 60),
|
||||
503: (60, 5 * 60, 30 * 60),
|
||||
504: (60, 5 * 60, 30 * 60),
|
||||
},
|
||||
network_cooldowns=(60, 5 * 60, 30 * 60),
|
||||
),
|
||||
|
||||
"crossref": ProviderPolicy(
|
||||
status_cooldowns={
|
||||
429: (2 * 60, 10 * 60, 60 * 60),
|
||||
500: (60, 5 * 60, 30 * 60),
|
||||
502: (60, 5 * 60, 30 * 60),
|
||||
503: (60, 5 * 60, 30 * 60),
|
||||
504: (60, 5 * 60, 30 * 60),
|
||||
},
|
||||
network_cooldowns=(60, 5 * 60, 30 * 60),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
def __init__(self, state_file: str | Path):
|
||||
self.state_file = Path(state_file)
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state = self._load()
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
if not self.state_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with self.state_file.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to load circuit breaker state: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
return {}
|
||||
|
||||
def _save(self) -> None:
|
||||
"""
|
||||
임시 파일 작성 후 atomic replace.
|
||||
저장 중 프로세스가 죽어도 JSON 파일이 깨질 가능성을 낮춘다.
|
||||
"""
|
||||
tmp_file = self.state_file.with_suffix(
|
||||
self.state_file.suffix + ".tmp"
|
||||
)
|
||||
|
||||
with tmp_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
self.state,
|
||||
f,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
os.replace(tmp_file, self.state_file)
|
||||
|
||||
def _provider_state(self, provider: str) -> dict[str, Any]:
|
||||
if provider not in self.state:
|
||||
self.state[provider] = {
|
||||
"open_until": 0.0,
|
||||
"reason": None,
|
||||
"strikes": {},
|
||||
}
|
||||
|
||||
return self.state[provider]
|
||||
|
||||
def is_open(self, provider: str) -> bool:
|
||||
state = self._provider_state(provider)
|
||||
|
||||
open_until = float(state.get("open_until", 0.0))
|
||||
|
||||
return time.time() < open_until
|
||||
|
||||
def remaining_seconds(self, provider: str) -> int:
|
||||
state = self._provider_state(provider)
|
||||
|
||||
remaining = float(state.get("open_until", 0.0)) - time.time()
|
||||
|
||||
return max(0, int(remaining))
|
||||
|
||||
def reason(self, provider: str) -> str | None:
|
||||
return self._provider_state(provider).get("reason")
|
||||
|
||||
def record_success(self, provider: str) -> None:
|
||||
"""
|
||||
실제 provider 요청이 성공했으면 이전 failure strike를 초기화한다.
|
||||
"""
|
||||
old_state = self._provider_state(provider)
|
||||
|
||||
had_failure = (
|
||||
old_state.get("strikes")
|
||||
or old_state.get("open_until", 0.0) > 0
|
||||
)
|
||||
|
||||
self.state[provider] = {
|
||||
"open_until": 0.0,
|
||||
"reason": None,
|
||||
"strikes": {},
|
||||
}
|
||||
|
||||
if had_failure:
|
||||
self._save()
|
||||
logger.info(
|
||||
"[CircuitBreaker] %s recovered; state reset",
|
||||
provider,
|
||||
)
|
||||
|
||||
def record_failure(
|
||||
self,
|
||||
provider: str,
|
||||
failure_key: str,
|
||||
cooldowns: tuple[int, ...],
|
||||
) -> int:
|
||||
state = self._provider_state(provider)
|
||||
|
||||
strikes = state.setdefault("strikes", {})
|
||||
|
||||
strike = int(strikes.get(failure_key, 0)) + 1
|
||||
strikes[failure_key] = strike
|
||||
|
||||
cooldown_index = min(
|
||||
strike - 1,
|
||||
len(cooldowns) - 1,
|
||||
)
|
||||
|
||||
cooldown = cooldowns[cooldown_index]
|
||||
|
||||
state["open_until"] = time.time() + cooldown
|
||||
state["reason"] = failure_key
|
||||
|
||||
self._save()
|
||||
|
||||
logger.warning(
|
||||
"[CircuitBreaker] OPEN provider=%s reason=%s "
|
||||
"strike=%d cooldown=%ds",
|
||||
provider,
|
||||
failure_key,
|
||||
strike,
|
||||
cooldown,
|
||||
)
|
||||
|
||||
return cooldown
|
||||
|
||||
|
||||
def get_provider_name(collector: Any) -> str:
|
||||
"""
|
||||
예:
|
||||
paper_monitor.collectors.ieee
|
||||
-> ieee
|
||||
|
||||
paper_monitor.collectors.semantic_scholar
|
||||
-> semantic_scholar
|
||||
"""
|
||||
module_name = collector.__class__.__module__
|
||||
|
||||
return module_name.rsplit(".", 1)[-1].lower()
|
||||
|
||||
|
||||
def extract_http_status(exc: Exception) -> int | None:
|
||||
"""
|
||||
requests.HTTPError뿐 아니라 IEEEAPIError 같은 custom exception도
|
||||
message 안에 403 등이 있으면 탐지한다.
|
||||
"""
|
||||
|
||||
# requests.HTTPError
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
response = getattr(exc, "response", None)
|
||||
|
||||
if response is not None:
|
||||
return response.status_code
|
||||
|
||||
# custom exception이 status_code를 갖는 경우
|
||||
status = getattr(exc, "status_code", None)
|
||||
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
|
||||
# custom exception 내부에 response가 있는 경우
|
||||
response = getattr(exc, "response", None)
|
||||
|
||||
if response is not None:
|
||||
status = getattr(response, "status_code", None)
|
||||
|
||||
if isinstance(status, int):
|
||||
return status
|
||||
|
||||
# 마지막 fallback:
|
||||
# "HTTP 403", "403 Forbidden", "status=403" 등
|
||||
text = str(exc)
|
||||
|
||||
match = re.search(
|
||||
r"\b(400|401|403|404|408|409|425|429|500|502|503|504)\b",
|
||||
text,
|
||||
)
|
||||
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_network_error(exc: Exception) -> bool:
|
||||
return isinstance(
|
||||
exc,
|
||||
(
|
||||
requests.Timeout,
|
||||
requests.ConnectionError,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
STATE_FILE = os.getenv(
|
||||
"CIRCUIT_BREAKER_STATE",
|
||||
"/state/provider_circuit_breakers.json",
|
||||
)
|
||||
|
||||
breaker = CircuitBreaker(STATE_FILE)
|
||||
|
||||
|
||||
def protected_search(collector: Any, query: str):
|
||||
provider = get_provider_name(collector)
|
||||
|
||||
policy = PROVIDER_POLICIES.get(provider)
|
||||
|
||||
# 별도 정책이 없는 collector는 기존 방식 그대로
|
||||
if policy is None:
|
||||
return collector.search(query)
|
||||
|
||||
# ============================================
|
||||
# Circuit OPEN
|
||||
# ============================================
|
||||
|
||||
if breaker.is_open(provider):
|
||||
remaining = breaker.remaining_seconds(provider)
|
||||
reason = breaker.reason(provider)
|
||||
|
||||
logger.warning(
|
||||
"[CircuitBreaker] SKIP provider=%s "
|
||||
"reason=%s remaining=%ds",
|
||||
provider,
|
||||
reason,
|
||||
remaining,
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
# ============================================
|
||||
# 실제 provider 호출
|
||||
# ============================================
|
||||
|
||||
try:
|
||||
result = collector.search(query)
|
||||
|
||||
except Exception as exc:
|
||||
status = extract_http_status(exc)
|
||||
|
||||
# ----------------------------------------
|
||||
# HTTP failure
|
||||
# ----------------------------------------
|
||||
|
||||
if status is not None:
|
||||
cooldowns = policy.status_cooldowns.get(status)
|
||||
|
||||
if cooldowns:
|
||||
breaker.record_failure(
|
||||
provider,
|
||||
f"http_{status}",
|
||||
cooldowns,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"[%s] request failed with HTTP %s; "
|
||||
"provider disabled temporarily",
|
||||
provider,
|
||||
status,
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
# ----------------------------------------
|
||||
# Network failure
|
||||
# ----------------------------------------
|
||||
|
||||
if (
|
||||
is_network_error(exc)
|
||||
and policy.network_cooldowns is not None
|
||||
):
|
||||
breaker.record_failure(
|
||||
provider,
|
||||
"network_error",
|
||||
policy.network_cooldowns,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"[%s] network error; "
|
||||
"provider disabled temporarily: %s",
|
||||
provider,
|
||||
exc,
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
# circuit breaker 대상이 아닌 exception은
|
||||
# 기존 코드에서 처리할 수 있도록 다시 raise
|
||||
raise
|
||||
|
||||
# ============================================
|
||||
# 성공
|
||||
# ============================================
|
||||
|
||||
breaker.record_success(provider)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import requests
|
||||
from ..models import Paper
|
||||
from ..util import clean_text,normalize_doi,parse_date
|
||||
|
||||
class CrossrefCollector:
|
||||
def __init__(self,base_url,mailto,lookback_days,limit):
|
||||
self.base_url,self.mailto,self.lookback_days,self.limit=base_url,mailto,lookback_days,min(limit,100)
|
||||
def search(self,query):
|
||||
since=(datetime.now(timezone.utc).date()-timedelta(days=self.lookback_days)).isoformat()
|
||||
params={'query.bibliographic':query,'filter':f'from-pub-date:{since}','rows':self.limit,'sort':'published','order':'desc'}
|
||||
if self.mailto: params['mailto']=self.mailto
|
||||
headers={'User-Agent':f'paper-monitor/1.0 (mailto:{self.mailto})' if self.mailto else 'paper-monitor/1.0'}
|
||||
r=requests.get(self.base_url,params=params,headers=headers,timeout=(10, 30)); r.raise_for_status()
|
||||
out=[]
|
||||
for a in r.json().get('message',{}).get('items',[]):
|
||||
title=clean_text((a.get('title') or [''])[0]);
|
||||
if not title: continue
|
||||
authors=[]
|
||||
for x in a.get('author',[]):
|
||||
name=' '.join(filter(None,[x.get('given'),x.get('family')])).strip()
|
||||
if name: authors.append(name)
|
||||
date=parse_date(a.get('published-print') or a.get('published-online') or a.get('published'))
|
||||
doi=normalize_doi(a.get('DOI')); container=a.get('container-title') or []
|
||||
out.append(Paper(title=title,authors=authors,abstract=clean_text(a.get('abstract')),doi=doi,url=a.get('URL') or (f'https://doi.org/{doi}' if doi else ''),venue=clean_text(container[0] if container else ''),publication_date=date,year=int(date[:4]) if len(date)>=4 and date[:4].isdigit() else None,source='Crossref',source_id=doi))
|
||||
return out
|
||||
@@ -0,0 +1,249 @@
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from ..models import Paper
|
||||
from ..util import (
|
||||
clean_text,
|
||||
normalize_doi,
|
||||
parse_date,
|
||||
within_lookback,
|
||||
)
|
||||
|
||||
|
||||
class IEEEAPIError(RuntimeError):
|
||||
def __init__(self, status_code, message):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class IEEECollector:
|
||||
def __init__(
|
||||
self,
|
||||
base_url,
|
||||
api_key,
|
||||
lookback_days,
|
||||
limit,
|
||||
min_request_interval=5.0,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.lookback_days = lookback_days
|
||||
self.limit = limit
|
||||
|
||||
# Conservative client-side pacing.
|
||||
# This is NOT an IEEE-defined fixed rate limit.
|
||||
self.min_request_interval = min_request_interval
|
||||
self._last_request_time = 0.0
|
||||
|
||||
self.session = requests.Session()
|
||||
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Accept": "application/json",
|
||||
"User-Agent": (
|
||||
"paper-monitor/1.0 "
|
||||
"(academic literature monitoring)"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def _wait_for_rate_limit(self):
|
||||
elapsed = (
|
||||
time.monotonic()
|
||||
- self._last_request_time
|
||||
)
|
||||
|
||||
wait = (
|
||||
self.min_request_interval
|
||||
- elapsed
|
||||
)
|
||||
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
|
||||
def _request(self, params):
|
||||
self._wait_for_rate_limit()
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
timeout=(10, 30),
|
||||
)
|
||||
|
||||
self._last_request_time = (
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
self._last_request_time = (
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
# Do not expose the original requests exception,
|
||||
# because its URL may contain the API key.
|
||||
raise IEEEAPIError(
|
||||
None,
|
||||
"IEEE API request timed out",
|
||||
) from None
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
self._last_request_time = (
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
raise IEEEAPIError(
|
||||
None,
|
||||
"IEEE API connection failed",
|
||||
) from None
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
self._last_request_time = (
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
raise IEEEAPIError(
|
||||
None,
|
||||
"IEEE API request failed",
|
||||
) from None
|
||||
|
||||
status = response.status_code
|
||||
|
||||
if status == 401:
|
||||
raise IEEEAPIError(
|
||||
401,
|
||||
"IEEE API authentication failed",
|
||||
)
|
||||
|
||||
if status == 403:
|
||||
raise IEEEAPIError(
|
||||
403,
|
||||
"IEEE API access forbidden",
|
||||
)
|
||||
|
||||
if status == 429:
|
||||
retry_after = response.headers.get(
|
||||
"Retry-After"
|
||||
)
|
||||
|
||||
if retry_after:
|
||||
raise IEEEAPIError(
|
||||
429,
|
||||
(
|
||||
"IEEE API rate limited "
|
||||
f"(Retry-After={retry_after})"
|
||||
),
|
||||
)
|
||||
|
||||
raise IEEEAPIError(
|
||||
429,
|
||||
"IEEE API rate limited",
|
||||
)
|
||||
|
||||
if status >= 400:
|
||||
raise IEEEAPIError(
|
||||
status,
|
||||
f"IEEE API HTTP error {status}",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def search(self, query):
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
params = {
|
||||
"apikey": self.api_key,
|
||||
"format": "json",
|
||||
"max_records": min(
|
||||
self.limit,
|
||||
200,
|
||||
),
|
||||
"start_record": 1,
|
||||
"sort_order": "desc",
|
||||
"sort_field": "publication_year",
|
||||
"querytext": query,
|
||||
}
|
||||
|
||||
response = self._request(params)
|
||||
|
||||
out = []
|
||||
|
||||
for article in (
|
||||
response.json()
|
||||
.get("articles", [])
|
||||
):
|
||||
date = parse_date(
|
||||
article.get("publication_date")
|
||||
or article.get(
|
||||
"publication_year"
|
||||
)
|
||||
)
|
||||
|
||||
if not within_lookback(
|
||||
date,
|
||||
self.lookback_days,
|
||||
):
|
||||
continue
|
||||
|
||||
authors = [
|
||||
clean_text(
|
||||
author.get("full_name")
|
||||
)
|
||||
for author in (
|
||||
article.get("authors")
|
||||
or {}
|
||||
).get("authors", [])
|
||||
if author.get("full_name")
|
||||
]
|
||||
|
||||
year_text = str(
|
||||
article.get(
|
||||
"publication_year",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
year = (
|
||||
int(year_text)
|
||||
if year_text.isdigit()
|
||||
else None
|
||||
)
|
||||
|
||||
paper = Paper(
|
||||
title=clean_text(
|
||||
article.get("title")
|
||||
),
|
||||
authors=authors,
|
||||
abstract=clean_text(
|
||||
article.get("abstract")
|
||||
),
|
||||
doi=normalize_doi(
|
||||
article.get("doi")
|
||||
),
|
||||
url=(
|
||||
article.get("html_url")
|
||||
or article.get("pdf_url")
|
||||
or ""
|
||||
),
|
||||
venue=clean_text(
|
||||
article.get(
|
||||
"publication_title"
|
||||
)
|
||||
),
|
||||
publication_date=date,
|
||||
year=year,
|
||||
source="IEEE Xplore",
|
||||
source_id=str(
|
||||
article.get(
|
||||
"article_number"
|
||||
)
|
||||
or ""
|
||||
),
|
||||
)
|
||||
|
||||
if paper.title:
|
||||
out.append(paper)
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,65 @@
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import email, imaplib, re
|
||||
from email.header import decode_header, make_header
|
||||
from bs4 import BeautifulSoup
|
||||
from ..models import Paper
|
||||
from ..util import clean_text, normalize_doi, DOI_RE
|
||||
|
||||
def _decode(v):
|
||||
if not v: return ''
|
||||
try: return str(make_header(decode_header(v)))
|
||||
except Exception: return v
|
||||
|
||||
class ScholarGmailCollector:
|
||||
def __init__(self,address,app_password,imap_host,mailbox,sender_contains,subject_contains,lookback_days):
|
||||
self.address,self.password,self.host,self.mailbox=address,app_password,imap_host,mailbox
|
||||
self.sender_contains,self.subject_contains=sender_contains.lower(),subject_contains.lower()
|
||||
self.lookback_days=lookback_days
|
||||
def collect(self):
|
||||
if not self.address or not self.password: return []
|
||||
since=(datetime.now(timezone.utc)-timedelta(days=self.lookback_days)).strftime('%d-%b-%Y')
|
||||
with imaplib.IMAP4_SSL(self.host) as m:
|
||||
m.login(self.address,self.password); m.select(self.mailbox,readonly=True)
|
||||
typ,data=m.search(None,'SINCE',since)
|
||||
if typ!='OK': return []
|
||||
papers=[]
|
||||
for num in data[0].split():
|
||||
typ,msgdata=m.fetch(num,'(RFC822)')
|
||||
if typ!='OK': continue
|
||||
msg=email.message_from_bytes(msgdata[0][1])
|
||||
sender,subject=_decode(msg.get('From')).lower(),_decode(msg.get('Subject')).lower()
|
||||
if self.sender_contains and self.sender_contains not in sender: continue
|
||||
if self.subject_contains and self.subject_contains not in subject: continue
|
||||
papers.extend(self._parse_message(msg))
|
||||
return papers
|
||||
def _parse_message(self,msg):
|
||||
html_body=text_body=''
|
||||
parts=msg.walk() if msg.is_multipart() else [msg]
|
||||
for part in parts:
|
||||
if 'attachment' in str(part.get('Content-Disposition','')).lower(): continue
|
||||
payload=part.get_payload(decode=True)
|
||||
if not payload: continue
|
||||
body=payload.decode(part.get_content_charset() or 'utf-8',errors='replace')
|
||||
if part.get_content_type()=='text/html': html_body+=body
|
||||
elif part.get_content_type()=='text/plain': text_body+=body
|
||||
date=''
|
||||
try: date=email.utils.parsedate_to_datetime(msg.get('Date')).date().isoformat()
|
||||
except Exception: pass
|
||||
papers=[]
|
||||
if html_body:
|
||||
soup=BeautifulSoup(html_body,'html.parser')
|
||||
for a in soup.find_all('a',href=True):
|
||||
title=clean_text(a.get_text(' ',strip=True)); href=a['href']
|
||||
if len(title)<15: continue
|
||||
if any(x in title.lower() for x in ['create alert','cancel alert','view all','unsubscribe']): continue
|
||||
context=clean_text(a.parent.get_text(' ',strip=True) if a.parent else title)
|
||||
doi_match=DOI_RE.search(context+' '+href)
|
||||
papers.append(Paper(title=title,abstract=context if context!=title else '',doi=normalize_doi(doi_match.group(0) if doi_match else ''),url=href,publication_date=date,source='Google Scholar Alert'))
|
||||
else:
|
||||
for line in text_body.splitlines():
|
||||
m=re.search(r'(https?://\S+)',line)
|
||||
if m:
|
||||
title=clean_text(line[:m.start()])
|
||||
if len(title)>=15: papers.append(Paper(title=title,url=m.group(1),publication_date=date,source='Google Scholar Alert'))
|
||||
uniq={p.title.lower():p for p in papers}
|
||||
return list(uniq.values())
|
||||
@@ -0,0 +1,192 @@
|
||||
import random
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from ..models import Paper
|
||||
|
||||
|
||||
class SemanticScholarCollector:
|
||||
def __init__(
|
||||
self,
|
||||
api_key,
|
||||
lookback_days,
|
||||
limit=20,
|
||||
min_request_interval=2.5,
|
||||
max_retries=3,
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.lookback_days = lookback_days
|
||||
self.limit = limit
|
||||
self.min_request_interval = min_request_interval
|
||||
self.max_retries = max_retries
|
||||
|
||||
self.base_url = (
|
||||
"https://api.semanticscholar.org/graph/v1/paper/search"
|
||||
)
|
||||
|
||||
self._last_request_time = 0.0
|
||||
|
||||
def _wait_for_rate_limit(self):
|
||||
now = time.monotonic()
|
||||
|
||||
elapsed = now - self._last_request_time
|
||||
|
||||
wait = self.min_request_interval - elapsed
|
||||
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
|
||||
def _request(self, params):
|
||||
headers = {}
|
||||
|
||||
if self.api_key:
|
||||
headers["x-api-key"] = self.api_key
|
||||
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
self._wait_for_rate_limit()
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=(10, 30),
|
||||
)
|
||||
|
||||
self._last_request_time = time.monotonic()
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = response.headers.get(
|
||||
"Retry-After"
|
||||
)
|
||||
|
||||
if retry_after:
|
||||
try:
|
||||
delay = float(retry_after)
|
||||
except ValueError:
|
||||
delay = None
|
||||
else:
|
||||
delay = None
|
||||
|
||||
if delay is None:
|
||||
delay = (
|
||||
2.5 * (2 ** attempt)
|
||||
+ random.uniform(0.2, 0.8)
|
||||
)
|
||||
|
||||
print(
|
||||
"Semantic Scholar rate limited. "
|
||||
f"Retrying after {delay:.1f} sec..."
|
||||
)
|
||||
|
||||
time.sleep(delay)
|
||||
last_error = requests.exceptions.HTTPError(
|
||||
f"429 Too Many Requests for {response.url}",
|
||||
response=response,
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
except (
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError,
|
||||
) as exc:
|
||||
self._last_request_time = time.monotonic()
|
||||
|
||||
last_error = exc
|
||||
|
||||
if attempt >= self.max_retries - 1:
|
||||
raise
|
||||
|
||||
delay = (
|
||||
2.5 * (2 ** attempt)
|
||||
+ random.uniform(0.2, 0.8)
|
||||
)
|
||||
|
||||
print(
|
||||
"Semantic Scholar network error. "
|
||||
f"Retrying after {delay:.1f} sec..."
|
||||
)
|
||||
|
||||
time.sleep(delay)
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
raise RuntimeError(
|
||||
"Semantic Scholar request failed"
|
||||
)
|
||||
|
||||
def search(self, query):
|
||||
params = {
|
||||
"query": query,
|
||||
"limit": self.limit,
|
||||
"fields": (
|
||||
"paperId,"
|
||||
"title,"
|
||||
"abstract,"
|
||||
"authors,"
|
||||
"year,"
|
||||
"venue,"
|
||||
"publicationDate,"
|
||||
"citationCount,"
|
||||
"externalIds,"
|
||||
"url,"
|
||||
"openAccessPdf"
|
||||
),
|
||||
}
|
||||
|
||||
response = self._request(params)
|
||||
|
||||
data = response.json()
|
||||
|
||||
papers = []
|
||||
|
||||
for item in data.get("data", []):
|
||||
external_ids = item.get("externalIds") or {}
|
||||
|
||||
doi = external_ids.get("DOI") or ""
|
||||
|
||||
authors = [
|
||||
a.get("name", "")
|
||||
for a in item.get("authors", [])
|
||||
if a.get("name")
|
||||
]
|
||||
|
||||
open_access = item.get("openAccessPdf") or {}
|
||||
|
||||
pdf_url = (
|
||||
open_access.get("url")
|
||||
if isinstance(open_access, dict)
|
||||
else ""
|
||||
) or ""
|
||||
|
||||
paper = Paper(
|
||||
title=item.get("title") or "",
|
||||
authors=authors,
|
||||
abstract=item.get("abstract") or "",
|
||||
doi=doi,
|
||||
url=item.get("url") or "",
|
||||
venue=item.get("venue") or "",
|
||||
publication_date=(
|
||||
item.get("publicationDate") or ""
|
||||
),
|
||||
year=item.get("year"),
|
||||
citation_count=item.get(
|
||||
"citationCount"
|
||||
),
|
||||
source="Semantic Scholar",
|
||||
source_id=item.get("paperId") or "",
|
||||
pdf_url=pdf_url,
|
||||
)
|
||||
|
||||
papers.append(paper)
|
||||
|
||||
return papers
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
import os, yaml
|
||||
|
||||
def load_config(path: str) -> dict:
|
||||
p = Path(path).expanduser().resolve()
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
cfg["_config_dir"] = str(p.parent)
|
||||
return cfg
|
||||
|
||||
def env_value(name: str | None, default: str = "") -> str:
|
||||
return os.getenv(name, default) if name else default
|
||||
|
||||
def resolve_path(cfg: dict, value: str) -> str:
|
||||
p = Path(value)
|
||||
return str(p if p.is_absolute() else (Path(cfg["_config_dir"]) / p).resolve())
|
||||
@@ -0,0 +1,241 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from .models import Paper
|
||||
from .util import paper_key
|
||||
|
||||
SCHEMA = '''
|
||||
CREATE TABLE IF NOT EXISTS papers (
|
||||
paper_key TEXT PRIMARY KEY,
|
||||
doi TEXT,
|
||||
title TEXT NOT NULL,
|
||||
authors TEXT,
|
||||
abstract TEXT,
|
||||
url TEXT,
|
||||
venue TEXT,
|
||||
publication_date TEXT,
|
||||
year INTEGER,
|
||||
citation_count INTEGER,
|
||||
source TEXT,
|
||||
source_id TEXT,
|
||||
categories TEXT,
|
||||
relevance INTEGER DEFAULT 0,
|
||||
summary TEXT,
|
||||
ai_reason TEXT,
|
||||
first_seen TEXT NOT NULL,
|
||||
first_seen_local_date TEXT,
|
||||
last_seen TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_papers_pubdate ON papers(publication_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_papers_relevance ON papers(relevance);
|
||||
CREATE INDEX IF NOT EXISTS idx_papers_first_seen_local_date ON papers(first_seen_local_date);
|
||||
'''
|
||||
|
||||
class PaperDB:
|
||||
def __init__(self, path):
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.conn = sqlite3.connect(path)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.conn.executescript(SCHEMA)
|
||||
self._migrate()
|
||||
|
||||
def _migrate(self):
|
||||
cols = {
|
||||
r['name']
|
||||
for r in self.conn.execute(
|
||||
'PRAGMA table_info(papers)'
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
if 'first_seen_local_date' not in cols:
|
||||
self.conn.execute(
|
||||
'ALTER TABLE papers ADD COLUMN first_seen_local_date TEXT'
|
||||
)
|
||||
|
||||
if 'oa_status' not in cols:
|
||||
self.conn.execute(
|
||||
'ALTER TABLE papers ADD COLUMN oa_status TEXT'
|
||||
)
|
||||
|
||||
if 'pdf_url' not in cols:
|
||||
self.conn.execute(
|
||||
'ALTER TABLE papers ADD COLUMN pdf_url TEXT'
|
||||
)
|
||||
|
||||
if 'ai_analysis_level' not in cols:
|
||||
self.conn.execute(
|
||||
'ALTER TABLE papers ADD COLUMN ai_analysis_level TEXT'
|
||||
)
|
||||
|
||||
if 'ai_status' not in cols:
|
||||
self.conn.execute(
|
||||
'ALTER TABLE papers ADD COLUMN ai_status TEXT'
|
||||
)
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def exists(self, p: Paper) -> bool:
|
||||
k = paper_key(p.doi, p.title)
|
||||
row = self.conn.execute(
|
||||
'SELECT 1 FROM papers WHERE paper_key=? LIMIT 1',
|
||||
(k,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def upsert(self, p: Paper, local_date: str = ''):
|
||||
k = paper_key(p.doi, p.title)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
old = self.conn.execute('SELECT * FROM papers WHERE paper_key=?', (k,)).fetchone()
|
||||
is_new = old is None
|
||||
authors, cats = '|||'.join(p.authors), '|||'.join(p.categories)
|
||||
if is_new:
|
||||
self.conn.execute('''INSERT INTO papers
|
||||
(paper_key,doi,title,authors,abstract,url,venue,publication_date,year,citation_count,source,source_id,categories,relevance,summary,ai_reason,oa_status,pdf_url,ai_analysis_level,ai_status,first_seen,first_seen_local_date,last_seen)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||||
(
|
||||
k,
|
||||
p.doi,
|
||||
p.title,
|
||||
authors,
|
||||
p.abstract,
|
||||
p.url,
|
||||
p.venue,
|
||||
p.publication_date,
|
||||
p.year,
|
||||
p.citation_count,
|
||||
p.source,
|
||||
p.source_id,
|
||||
cats,
|
||||
p.relevance,
|
||||
p.summary,
|
||||
p.ai_reason,
|
||||
p.oa_status,
|
||||
p.pdf_url,
|
||||
p.ai_analysis_level,
|
||||
p.ai_status,
|
||||
now,
|
||||
local_date,
|
||||
now,
|
||||
))
|
||||
else:
|
||||
def choose(new, oldv): return new if new not in ('', None, [], 0) else oldv
|
||||
self.conn.execute('''UPDATE papers SET
|
||||
doi=?,
|
||||
title=?,
|
||||
authors=?,
|
||||
abstract=?,
|
||||
url=?,
|
||||
venue=?,
|
||||
publication_date=?,
|
||||
year=?,
|
||||
citation_count=?,
|
||||
source=?,
|
||||
source_id=?,
|
||||
categories=?,
|
||||
relevance=?,
|
||||
summary=?,
|
||||
ai_reason=?,
|
||||
oa_status=?,
|
||||
pdf_url=?,
|
||||
ai_analysis_level=?,
|
||||
ai_status=?,
|
||||
last_seen=?
|
||||
WHERE paper_key=?''',
|
||||
(
|
||||
choose(p.doi, old['doi']),
|
||||
choose(p.title, old['title']),
|
||||
choose(authors, old['authors']),
|
||||
choose(p.abstract, old['abstract']),
|
||||
choose(p.url, old['url']),
|
||||
choose(p.venue, old['venue']),
|
||||
choose(p.publication_date, old['publication_date']),
|
||||
choose(p.year, old['year']),
|
||||
choose(p.citation_count, old['citation_count']),
|
||||
choose(p.source, old['source']),
|
||||
choose(p.source_id, old['source_id']),
|
||||
choose(cats, old['categories']),
|
||||
max(p.relevance, old['relevance'] or 0),
|
||||
choose(p.summary, old['summary']),
|
||||
choose(p.ai_reason, old['ai_reason']),
|
||||
choose(p.oa_status, old['oa_status']),
|
||||
choose(p.pdf_url, old['pdf_url']),
|
||||
choose(p.ai_analysis_level, old['ai_analysis_level']),
|
||||
choose(p.ai_status, old['ai_status']),
|
||||
now,
|
||||
k,
|
||||
))
|
||||
self.conn.commit()
|
||||
return is_new
|
||||
|
||||
def list_first_seen_on(self, local_date: str) -> list[Paper]:
|
||||
rows = self.conn.execute('SELECT * FROM papers WHERE first_seen_local_date=? ORDER BY relevance DESC, publication_date DESC', (local_date,)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append(Paper(
|
||||
title=r['title'],
|
||||
authors=(r['authors'] or '').split('|||') if r['authors'] else [],
|
||||
abstract=r['abstract'] or '',
|
||||
doi=r['doi'] or '',
|
||||
url=r['url'] or '',
|
||||
venue=r['venue'] or '',
|
||||
publication_date=r['publication_date'] or '',
|
||||
year=r['year'],
|
||||
citation_count=r['citation_count'],
|
||||
source=r['source'] or '',
|
||||
source_id=r['source_id'] or '',
|
||||
categories=(r['categories'] or '').split('|||') if r['categories'] else [],
|
||||
relevance=r['relevance'] or 0,
|
||||
summary=r['summary'] or '',
|
||||
ai_reason=r['ai_reason'] or '',
|
||||
oa_status=r['oa_status'] or '',
|
||||
pdf_url=r['pdf_url'] or '',
|
||||
ai_analysis_level=r['ai_analysis_level'] or '',
|
||||
ai_status=r['ai_status'] or '',
|
||||
))
|
||||
return out
|
||||
|
||||
def list_ai_failed(self, limit: int = 20) -> list[Paper]:
|
||||
rows = self.conn.execute(
|
||||
'''
|
||||
SELECT *
|
||||
FROM papers
|
||||
WHERE ai_status='failed'
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?
|
||||
''',
|
||||
(limit,)
|
||||
).fetchall()
|
||||
|
||||
out = []
|
||||
|
||||
for r in rows:
|
||||
out.append(
|
||||
Paper(
|
||||
title=r['title'],
|
||||
authors=(r['authors'] or '').split('|||')
|
||||
if r['authors'] else [],
|
||||
abstract=r['abstract'] or '',
|
||||
doi=r['doi'] or '',
|
||||
url=r['url'] or '',
|
||||
venue=r['venue'] or '',
|
||||
publication_date=r['publication_date'] or '',
|
||||
year=r['year'],
|
||||
citation_count=r['citation_count'],
|
||||
source=r['source'] or '',
|
||||
source_id=r['source_id'] or '',
|
||||
categories=(r['categories'] or '').split('|||')
|
||||
if r['categories'] else [],
|
||||
relevance=r['relevance'] or 0,
|
||||
summary=r['summary'] or '',
|
||||
ai_reason=r['ai_reason'] or '',
|
||||
oa_status=r['oa_status'] or '',
|
||||
pdf_url=r['pdf_url'] or '',
|
||||
ai_analysis_level=r['ai_analysis_level'] or '',
|
||||
ai_status=r['ai_status'] or '',
|
||||
)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
def close(self):
|
||||
self.conn.close()
|
||||
@@ -0,0 +1,41 @@
|
||||
import requests
|
||||
class JoplinClient:
|
||||
def __init__(self,base_url,token): self.base=base_url.rstrip('/'); self.token=token; self.session=requests.Session()
|
||||
def _params(self,**kwargs): return {'token':self.token,**kwargs}
|
||||
def ping(self):
|
||||
r=self.session.get(f'{self.base}/ping',params=self._params(),timeout=10); r.raise_for_status(); return r.text
|
||||
def list_folders(self,page=1):
|
||||
r=self.session.get(f'{self.base}/folders',params=self._params(fields='id,title,parent_id',limit=100,page=page),timeout=15); r.raise_for_status(); return r.json()
|
||||
def ensure_folder_path(self,titles):
|
||||
parent_id=''
|
||||
for title in titles:
|
||||
found=None; page=1
|
||||
while True:
|
||||
data=self.list_folders(page)
|
||||
found=next((f for f in data.get('items',[]) if f.get('title')==title and (f.get('parent_id') or '')==parent_id),None)
|
||||
if found or not data.get('has_more'): break
|
||||
page+=1
|
||||
if not found:
|
||||
r=self.session.post(f'{self.base}/folders',params=self._params(),json={'title':title,'parent_id':parent_id},timeout=15); r.raise_for_status(); found=r.json()
|
||||
parent_id=found['id']
|
||||
return parent_id
|
||||
def find_note_by_title(self,title,parent_id):
|
||||
page=1
|
||||
while True:
|
||||
r=self.session.get(f'{self.base}/notes',params=self._params(fields='id,title,parent_id',parent_id=parent_id,limit=100,page=page),timeout=15); r.raise_for_status(); data=r.json()
|
||||
n=next((x for x in data.get('items',[]) if x.get('title')==title),None)
|
||||
if n or not data.get('has_more'): return n
|
||||
page+=1
|
||||
def create_or_update_note(self,title,body,parent_id,update=True):
|
||||
old=self.find_note_by_title(title,parent_id) if update else None; payload={'title':title,'body':body,'parent_id':parent_id}
|
||||
if old: r=self.session.put(f'{self.base}/notes/{old["id"]}',params=self._params(),json=payload,timeout=30)
|
||||
else: r=self.session.post(f'{self.base}/notes',params=self._params(),json=payload,timeout=30)
|
||||
r.raise_for_status(); return r.json()['id']
|
||||
def ensure_tag(self,title):
|
||||
r=self.session.get(f'{self.base}/tags',params=self._params(fields='id,title',limit=100),timeout=15); r.raise_for_status()
|
||||
old=next((x for x in r.json().get('items',[]) if x.get('title')==title),None)
|
||||
if old: return old['id']
|
||||
r=self.session.post(f'{self.base}/tags',params=self._params(),json={'title':title},timeout=15); r.raise_for_status(); return r.json()['id']
|
||||
def add_tag_to_note(self,tag_id,note_id):
|
||||
r=self.session.post(f'{self.base}/tags/{tag_id}/notes',params=self._params(),json={'id':note_id},timeout=15)
|
||||
if r.status_code not in (200,201,204): r.raise_for_status()
|
||||
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class Paper:
|
||||
title: str
|
||||
authors: list[str] = field(default_factory=list)
|
||||
abstract: str = ""
|
||||
doi: str = ""
|
||||
url: str = ""
|
||||
venue: str = ""
|
||||
publication_date: str = ""
|
||||
year: Optional[int] = None
|
||||
citation_count: Optional[int] = None
|
||||
source: str = ""
|
||||
source_id: str = ""
|
||||
categories: list[str] = field(default_factory=list)
|
||||
relevance: int = 0
|
||||
summary: str = ""
|
||||
ai_reason: str = ""
|
||||
oa_status: str = ""
|
||||
pdf_url: str = ""
|
||||
ai_analysis_level: str = ""
|
||||
ai_status: str = ""
|
||||
|
||||
@property
|
||||
def author_text(self) -> str:
|
||||
if len(self.authors) <= 4:
|
||||
return ", ".join(self.authors)
|
||||
return ", ".join(self.authors[:3]) + " et al."
|
||||
@@ -0,0 +1,60 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnpaywallResolver:
|
||||
def __init__(self, email: str, timeout=(10, 30)):
|
||||
self.email = (email or "").strip()
|
||||
self.timeout = timeout
|
||||
self.base_url = "https://api.unpaywall.org/v2"
|
||||
|
||||
def resolve(self, paper):
|
||||
if not paper.doi:
|
||||
return paper
|
||||
|
||||
if not self.email:
|
||||
log.warning(
|
||||
"Unpaywall email missing; OA lookup skipped: %s",
|
||||
paper.title,
|
||||
)
|
||||
return paper
|
||||
|
||||
doi = paper.doi.strip()
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}/{doi}",
|
||||
params={"email": self.email},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
except requests.exceptions.HTTPError as exc:
|
||||
status = exc.response.status_code if exc.response else None
|
||||
|
||||
# DOI not present in Unpaywall is not fatal.
|
||||
if status == 404:
|
||||
paper.oa_status = "not_found"
|
||||
return paper
|
||||
|
||||
raise
|
||||
|
||||
data = response.json()
|
||||
|
||||
paper.oa_status = str(
|
||||
data.get("oa_status") or ""
|
||||
)
|
||||
|
||||
best = data.get("best_oa_location") or {}
|
||||
|
||||
pdf_url = best.get("url_for_pdf") or ""
|
||||
|
||||
# Some OA records do not expose url_for_pdf but do expose a landing URL.
|
||||
# We intentionally do NOT treat url as a PDF here.
|
||||
paper.pdf_url = str(pdf_url).strip()
|
||||
|
||||
return paper
|
||||
@@ -0,0 +1,169 @@
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
class PDFDownloadError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PDFParseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def download_pdf(
|
||||
url: str,
|
||||
timeout=(10, 45),
|
||||
max_bytes: int = 25 * 1024 * 1024,
|
||||
) -> bytes:
|
||||
if not url:
|
||||
raise PDFDownloadError("PDF URL is empty")
|
||||
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"paper-monitor/1.0 "
|
||||
"(automated academic literature monitoring)"
|
||||
)
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
stream=True,
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = (
|
||||
response.headers.get("Content-Type", "")
|
||||
.lower()
|
||||
.split(";")[0]
|
||||
.strip()
|
||||
)
|
||||
|
||||
content_length = response.headers.get("Content-Length")
|
||||
|
||||
if content_length:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
|
||||
if declared_size > max_bytes:
|
||||
raise PDFDownloadError(
|
||||
f"PDF too large: {declared_size} bytes"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
data = bytearray()
|
||||
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
data.extend(chunk)
|
||||
|
||||
if len(data) > max_bytes:
|
||||
raise PDFDownloadError(
|
||||
f"PDF exceeded maximum size: {max_bytes} bytes"
|
||||
)
|
||||
|
||||
raw = bytes(data)
|
||||
|
||||
if not raw:
|
||||
raise PDFDownloadError("Downloaded PDF is empty")
|
||||
|
||||
# Some OA servers return application/octet-stream,
|
||||
# so do not rely only on Content-Type.
|
||||
is_pdf_content_type = content_type == "application/pdf"
|
||||
has_pdf_signature = raw[:5] == b"%PDF-"
|
||||
|
||||
if not is_pdf_content_type and not has_pdf_signature:
|
||||
raise PDFDownloadError(
|
||||
f"Downloaded content is not a PDF "
|
||||
f"(Content-Type={content_type or 'unknown'})"
|
||||
)
|
||||
|
||||
if not has_pdf_signature:
|
||||
raise PDFDownloadError(
|
||||
"Downloaded content does not have a valid PDF signature"
|
||||
)
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def extract_pdf_text(
|
||||
pdf_data: bytes,
|
||||
max_pages: int = 40,
|
||||
max_chars: int = 120000,
|
||||
) -> str:
|
||||
if not pdf_data:
|
||||
raise PDFParseError("PDF data is empty")
|
||||
|
||||
try:
|
||||
reader = PdfReader(
|
||||
BytesIO(pdf_data),
|
||||
strict=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise PDFParseError(
|
||||
f"Failed to open PDF: {exc}"
|
||||
) from exc
|
||||
|
||||
texts = []
|
||||
|
||||
page_count = min(
|
||||
len(reader.pages),
|
||||
max_pages,
|
||||
)
|
||||
|
||||
for page_index in range(page_count):
|
||||
try:
|
||||
text = reader.pages[page_index].extract_text() or ""
|
||||
except Exception:
|
||||
# One bad page should not make the whole document unusable.
|
||||
continue
|
||||
|
||||
text = text.strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
texts.append(text)
|
||||
|
||||
if sum(len(x) for x in texts) >= max_chars:
|
||||
break
|
||||
|
||||
result = "\n\n".join(texts).strip()
|
||||
|
||||
if not result:
|
||||
raise PDFParseError(
|
||||
"No extractable text found in PDF"
|
||||
)
|
||||
|
||||
if len(result) > max_chars:
|
||||
result = result[:max_chars]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def download_and_extract_pdf(
|
||||
url: str,
|
||||
timeout=(10, 45),
|
||||
max_bytes: int = 25 * 1024 * 1024,
|
||||
max_pages: int = 40,
|
||||
max_chars: int = 120000,
|
||||
) -> str:
|
||||
pdf_data = download_pdf(
|
||||
url,
|
||||
timeout=timeout,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
return extract_pdf_text(
|
||||
pdf_data,
|
||||
max_pages=max_pages,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
from pathlib import Path
|
||||
from .util import md_escape,safe_url
|
||||
|
||||
def stars(n): return '★'*max(1,min(5,int(n or 1)))
|
||||
|
||||
def render_markdown(papers,title,generated_at):
|
||||
papers=sorted(papers,key=lambda p:(p.relevance,p.publication_date or '',p.citation_count or 0),reverse=True)
|
||||
lines=[f'# {title}','',f'- Generated: {generated_at.isoformat(timespec="minutes")}',f'- Papers: {len(papers)}','','## New papers','','| Relevance | Category | Paper | Authors | Venue | Date | Citations | Source |','|---|---|---|---|---|---|---:|---|']
|
||||
for p in papers:
|
||||
link=f'[{md_escape(p.title)}]({safe_url(p.url)})' if p.url else md_escape(p.title)
|
||||
lines.append('| '+' | '.join([stars(p.relevance),md_escape(', '.join(p.categories)),link,md_escape(p.author_text),md_escape(p.venue),md_escape(p.publication_date),str(p.citation_count if p.citation_count is not None else ''),md_escape(p.source)])+' |')
|
||||
if papers:
|
||||
lines+=['','## Summaries','']
|
||||
for p in papers:
|
||||
lines += [
|
||||
f'### {p.title}',
|
||||
'',
|
||||
f'- **Relevance:** {stars(p.relevance)} ({p.relevance}/5)',
|
||||
f'- **Category:** {", ".join(p.categories)}',
|
||||
f'- **Authors:** {p.author_text or "-"}',
|
||||
f'- **Venue / Date:** {p.venue or "-"} / {p.publication_date or "-"}',
|
||||
f'- **DOI:** {p.doi or "-"}',
|
||||
f'- **Source:** {p.source}',
|
||||
]
|
||||
|
||||
if p.ai_analysis_level:
|
||||
level_text = {
|
||||
'full_text': 'Full text',
|
||||
'abstract': 'Abstract only',
|
||||
}.get(
|
||||
p.ai_analysis_level,
|
||||
p.ai_analysis_level
|
||||
)
|
||||
|
||||
lines.append(
|
||||
f'- **AI Analysis:** {level_text}'
|
||||
)
|
||||
|
||||
if p.oa_status:
|
||||
lines.append(
|
||||
f'- **OA Status:** {p.oa_status}'
|
||||
)
|
||||
|
||||
if p.summary:
|
||||
lines.append(
|
||||
f'- **AI Summary:** {p.summary}'
|
||||
)
|
||||
|
||||
elif p.abstract:
|
||||
short = (
|
||||
p.abstract[:500]
|
||||
+ ('…' if len(p.abstract) > 500 else '')
|
||||
)
|
||||
|
||||
lines.append(
|
||||
f'- **Abstract excerpt:** {short}'
|
||||
)
|
||||
|
||||
if p.ai_reason:
|
||||
lines.append(
|
||||
f'- **Why relevant:** {p.ai_reason}'
|
||||
)
|
||||
|
||||
if p.pdf_url:
|
||||
lines.append(
|
||||
f'- **OA PDF:** {p.pdf_url}'
|
||||
)
|
||||
|
||||
if p.url:
|
||||
lines.append(
|
||||
f'- **Link:** {p.url}'
|
||||
)
|
||||
return '\n'.join(lines).strip()+'\n'
|
||||
|
||||
def write_markdown(output_dir,filename,body):
|
||||
p=Path(output_dir); p.mkdir(parents=True,exist_ok=True); out=p/filename; out.write_text(body,encoding='utf-8'); return str(out)
|
||||
@@ -0,0 +1,579 @@
|
||||
import argparse
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
|
||||
from paper_monitor.circuit_breaker import protected_search
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .config import load_config, env_value, resolve_path
|
||||
from .db import PaperDB
|
||||
from .util import paper_key, clean_text
|
||||
from .scoring import classify_and_score
|
||||
from .render import render_markdown, write_markdown
|
||||
from .joplin import JoplinClient
|
||||
from .ai_enrich import (
|
||||
enrich_with_gemini,
|
||||
enrich_with_gemini_full_text,
|
||||
)
|
||||
from .collectors.ieee import (
|
||||
IEEECollector,
|
||||
IEEEAPIError,
|
||||
)
|
||||
from .collectors.semantic_scholar import SemanticScholarCollector
|
||||
from .collectors.crossref import CrossrefCollector
|
||||
from .collectors.scholar_gmail import ScholarGmailCollector
|
||||
from .oa_resolver import UnpaywallResolver
|
||||
from .pdf_utils import download_and_extract_pdf
|
||||
|
||||
log = logging.getLogger('paper_monitor')
|
||||
|
||||
|
||||
def merge_paper(dst, src):
|
||||
def longer(a, b):
|
||||
return b if len(b or '') > len(a or '') else a
|
||||
if not dst.doi and src.doi:
|
||||
dst.doi = src.doi
|
||||
dst.title = longer(dst.title, src.title)
|
||||
if len(src.authors) > len(dst.authors):
|
||||
dst.authors = src.authors
|
||||
dst.abstract = longer(dst.abstract, src.abstract)
|
||||
if not dst.url and src.url:
|
||||
dst.url = src.url
|
||||
dst.venue = longer(dst.venue, src.venue)
|
||||
if not dst.publication_date and src.publication_date:
|
||||
dst.publication_date = src.publication_date
|
||||
if not dst.year and src.year:
|
||||
dst.year = src.year
|
||||
if src.citation_count is not None:
|
||||
dst.citation_count = max(dst.citation_count or 0, src.citation_count)
|
||||
if src.source and src.source not in dst.source:
|
||||
dst.source = f'{dst.source} + {src.source}' if dst.source else src.source
|
||||
return dst
|
||||
|
||||
|
||||
def period(now, mode):
|
||||
if mode == 'weekly':
|
||||
iso = now.isocalendar()
|
||||
return f'{iso.year}-W{iso.week:02d}'
|
||||
return now.date().isoformat()
|
||||
|
||||
|
||||
def _status_code(exc):
|
||||
status = getattr(exc, 'status_code', None)
|
||||
|
||||
if status is not None:
|
||||
return status
|
||||
|
||||
response = getattr(exc, 'response', None)
|
||||
|
||||
if response is not None:
|
||||
return response.status_code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run(config_path, dry_run=False):
|
||||
cfg = load_config(config_path)
|
||||
app = cfg['app']
|
||||
search_cfg = cfg['search']
|
||||
sources = cfg.get('sources', {})
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, app.get('log_level', 'INFO').upper(), logging.INFO),
|
||||
format='%(asctime)s %(levelname)s %(message)s',
|
||||
)
|
||||
|
||||
now = datetime.now(ZoneInfo(app.get('timezone', 'Asia/Seoul')))
|
||||
local_date = now.date().isoformat()
|
||||
lookback = int(app.get('lookback_days', 14))
|
||||
limit = int(app.get('max_papers_per_source_per_query', 50))
|
||||
queries = search_cfg.get('queries', [])
|
||||
collected = []
|
||||
|
||||
if sources.get('ieee', {}).get('enabled'):
|
||||
s = sources['ieee']
|
||||
c = IEEECollector(s['base_url'], env_value(s.get('api_key_env')), lookback, limit)
|
||||
for q in queries:
|
||||
try:
|
||||
x = protected_search(c, q)
|
||||
log.info('IEEE %r -> %d', q, len(x))
|
||||
collected += x
|
||||
|
||||
except Exception as exc:
|
||||
log.exception(
|
||||
'IEEE failed: %r',
|
||||
q,
|
||||
)
|
||||
|
||||
if isinstance(
|
||||
exc,
|
||||
IEEEAPIError,
|
||||
):
|
||||
if exc.status_code is None:
|
||||
log.error(
|
||||
'IEEE network/request error. '
|
||||
'Stopping IEEE queries for this run.'
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
'IEEE HTTP %s. '
|
||||
'Stopping IEEE queries for this run.',
|
||||
exc.status_code,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
status = _status_code(exc)
|
||||
|
||||
if status in (
|
||||
401,
|
||||
403,
|
||||
429,
|
||||
):
|
||||
log.error(
|
||||
'IEEE HTTP %s. '
|
||||
'Stopping IEEE queries for this run.',
|
||||
status,
|
||||
)
|
||||
break
|
||||
|
||||
log.error(
|
||||
'Unexpected IEEE error. '
|
||||
'Stopping IEEE queries for this run.'
|
||||
)
|
||||
break
|
||||
|
||||
# Network problems
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError,
|
||||
),
|
||||
):
|
||||
log.error(
|
||||
'IEEE network error. '
|
||||
'Stopping IEEE queries for this run.'
|
||||
)
|
||||
break
|
||||
|
||||
if sources.get('semantic_scholar', {}).get('enabled'):
|
||||
s = sources['semantic_scholar']
|
||||
c = SemanticScholarCollector(s['base_url'], env_value(s.get('api_key_env')), lookback, limit)
|
||||
for q in queries:
|
||||
try:
|
||||
x = protected_search(c, q)
|
||||
log.info('Semantic Scholar %r -> %d', q, len(x))
|
||||
collected += x
|
||||
except Exception as exc:
|
||||
log.exception('Semantic Scholar failed: %r', q)
|
||||
status = _status_code(exc)
|
||||
# Authentication / authorization / rate-limit errors:
|
||||
# Remaining queries are unlikely to succeed in this run.
|
||||
if status in (401, 403, 429):
|
||||
log.error(
|
||||
'Semantic Scholar HTTP %s. '
|
||||
'Stopping Semantic Scholar queries for this run.',
|
||||
status,
|
||||
)
|
||||
break
|
||||
# Network problem:
|
||||
# Do not repeat the same timeout for every query.
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError,
|
||||
),
|
||||
):
|
||||
log.error(
|
||||
'Semantic Scholar network error. '
|
||||
'Stopping Semantic Scholar queries for this run.'
|
||||
)
|
||||
break
|
||||
|
||||
if sources.get('crossref', {}).get('enabled'):
|
||||
s = sources['crossref']
|
||||
c = CrossrefCollector(s['base_url'], env_value(s.get('mailto_env')), lookback, limit)
|
||||
for q in queries:
|
||||
try:
|
||||
x = protected_search(c, q)
|
||||
log.info('Crossref %r -> %d', q, len(x))
|
||||
collected += x
|
||||
|
||||
except Exception as exc:
|
||||
log.exception('Crossref failed: %r', q)
|
||||
|
||||
status = _status_code(exc)
|
||||
|
||||
# HTTP errors that are unlikely to recover during this run
|
||||
if status in (401, 403, 429):
|
||||
log.error(
|
||||
'Crossref HTTP %s. '
|
||||
'Stopping Crossref queries for this run.',
|
||||
status,
|
||||
)
|
||||
break
|
||||
|
||||
# Network problems
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ConnectionError,
|
||||
),
|
||||
):
|
||||
log.error(
|
||||
'Crossref network error. '
|
||||
'Stopping Crossref queries for this run.'
|
||||
)
|
||||
break
|
||||
|
||||
if sources.get('google_scholar_alert', {}).get('enabled'):
|
||||
s = sources['google_scholar_alert']
|
||||
c = ScholarGmailCollector(
|
||||
env_value(s.get('gmail_address_env')),
|
||||
env_value(s.get('gmail_app_password_env')),
|
||||
s.get('imap_host', 'imap.gmail.com'),
|
||||
s.get('mailbox', 'INBOX'),
|
||||
s.get('sender_contains', ''),
|
||||
s.get('subject_contains', ''),
|
||||
lookback,
|
||||
)
|
||||
try:
|
||||
x = c.collect()
|
||||
log.info('Google Scholar Alert -> %d', len(x))
|
||||
collected += x
|
||||
except Exception:
|
||||
log.exception('Google Scholar Alert failed')
|
||||
|
||||
merged = OrderedDict()
|
||||
for p in collected:
|
||||
p.title = clean_text(p.title)
|
||||
if not p.title:
|
||||
continue
|
||||
k = paper_key(p.doi, p.title)
|
||||
merged[k] = merge_paper(merged[k], p) if k in merged else p
|
||||
|
||||
papers = [classify_and_score(p, search_cfg) for p in merged.values()]
|
||||
papers = [p for p in papers if p.relevance >= int(app.get('min_relevance', 1))]
|
||||
|
||||
db_path = resolve_path(cfg, app.get('database_path', './data/papers.db'))
|
||||
db = PaperDB(db_path)
|
||||
new = []
|
||||
|
||||
try:
|
||||
# Determine which accepted papers are genuinely new before AI processing.
|
||||
new_candidates = [
|
||||
p for p in papers
|
||||
if not db.exists(p)
|
||||
]
|
||||
|
||||
new_keys = {
|
||||
paper_key(p.doi, p.title)
|
||||
for p in new_candidates
|
||||
}
|
||||
|
||||
# Resolve Open Access information for new papers only.
|
||||
oa = cfg.get('oa', {})
|
||||
|
||||
if oa.get('enabled'):
|
||||
email = env_value(
|
||||
oa.get('email_env', 'UNPAYWALL_EMAIL')
|
||||
)
|
||||
|
||||
if not email:
|
||||
log.warning(
|
||||
'Unpaywall email missing; '
|
||||
'OA lookup skipped'
|
||||
)
|
||||
|
||||
else:
|
||||
resolver = UnpaywallResolver(email)
|
||||
|
||||
oa_candidates = [
|
||||
p for p in new_candidates
|
||||
if (
|
||||
p.doi
|
||||
and p.doi.strip()
|
||||
and not (
|
||||
p.pdf_url
|
||||
and p.pdf_url.strip()
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
log.info(
|
||||
'Unpaywall candidates: %d / %d new papers',
|
||||
len(oa_candidates),
|
||||
len(new_candidates),
|
||||
)
|
||||
|
||||
for p in oa_candidates:
|
||||
try:
|
||||
resolver.resolve(p)
|
||||
|
||||
log.info(
|
||||
'Unpaywall resolved: status=%s pdf=%s title=%s',
|
||||
p.oa_status or '-',
|
||||
'YES' if p.pdf_url else 'NO',
|
||||
p.title,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# OA lookup failure must never stop paper collection.
|
||||
log.exception(
|
||||
'Unpaywall lookup failed; continuing without OA information: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 3. AI enrichment
|
||||
#
|
||||
# Candidates:
|
||||
#
|
||||
# A. Papers discovered for the first time in this run
|
||||
# B. Papers from earlier runs whose ai_status == "failed"
|
||||
#
|
||||
# Historical papers with ai_status == "" are NOT automatically
|
||||
# processed. This prevents unexpectedly processing the entire DB.
|
||||
# ============================================================
|
||||
|
||||
|
||||
ai = cfg.get('ai', {})
|
||||
|
||||
if ai.get('enabled'):
|
||||
api_key = env_value(
|
||||
ai.get('api_key_env', 'GEMINI_API_KEY')
|
||||
)
|
||||
|
||||
model = ai.get(
|
||||
'model',
|
||||
'gemini-3.6-flash',
|
||||
)
|
||||
|
||||
max_papers = int(
|
||||
ai.get('max_papers_per_run', 20)
|
||||
)
|
||||
|
||||
# Retry only papers explicitly marked as failed.
|
||||
# Existing historical papers with ai_status="" are NOT retried.
|
||||
retry_candidates = db.list_ai_failed(
|
||||
limit=max_papers
|
||||
)
|
||||
|
||||
# Avoid processing the same paper twice if it is somehow
|
||||
# present in both new_candidates and retry_candidates.
|
||||
candidate_map = {}
|
||||
|
||||
for p in new_candidates:
|
||||
candidate_map[paper_key(p.doi, p.title)] = p
|
||||
|
||||
for p in retry_candidates:
|
||||
k = paper_key(p.doi, p.title)
|
||||
|
||||
if k not in candidate_map:
|
||||
candidate_map[k] = p
|
||||
|
||||
ai_candidates = list(
|
||||
candidate_map.values()
|
||||
)
|
||||
|
||||
ai_candidates = sorted(
|
||||
ai_candidates,
|
||||
key=lambda x: x.relevance,
|
||||
reverse=True,
|
||||
)[:max_papers]
|
||||
|
||||
if not api_key:
|
||||
log.warning(
|
||||
'Gemini API key missing; AI enrichment skipped'
|
||||
)
|
||||
|
||||
else:
|
||||
log.info(
|
||||
'Gemini enrichment candidates: %d '
|
||||
'(new=%d retry=%d)',
|
||||
len(ai_candidates),
|
||||
len(new_candidates),
|
||||
len(retry_candidates),
|
||||
)
|
||||
|
||||
for p in ai_candidates:
|
||||
full_text_done = False
|
||||
abstract_done = False
|
||||
had_failure = False
|
||||
|
||||
# -----------------------------------------
|
||||
# 1. Prefer OA PDF full-text analysis
|
||||
# -----------------------------------------
|
||||
if p.pdf_url and p.pdf_url.strip():
|
||||
try:
|
||||
log.info(
|
||||
'Downloading OA PDF: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
full_text = download_and_extract_pdf(
|
||||
p.pdf_url
|
||||
)
|
||||
|
||||
log.info(
|
||||
'PDF extracted: chars=%d title=%s',
|
||||
len(full_text),
|
||||
p.title,
|
||||
)
|
||||
|
||||
enrich_with_gemini_full_text(
|
||||
p,
|
||||
full_text,
|
||||
api_key,
|
||||
model,
|
||||
)
|
||||
|
||||
full_text_done = True
|
||||
p.ai_status = 'done'
|
||||
|
||||
log.info(
|
||||
'Gemini full-text enriched: '
|
||||
'relevance=%d categories=%s title=%s',
|
||||
p.relevance,
|
||||
p.categories,
|
||||
p.title,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
had_failure = True
|
||||
|
||||
log.exception(
|
||||
'Full-text AI failed; '
|
||||
'trying abstract fallback: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -----------------------------------------
|
||||
# 2. Abstract fallback
|
||||
# -----------------------------------------
|
||||
if (
|
||||
not full_text_done
|
||||
and p.abstract
|
||||
and p.abstract.strip()
|
||||
):
|
||||
try:
|
||||
enrich_with_gemini(
|
||||
p,
|
||||
api_key,
|
||||
model,
|
||||
)
|
||||
|
||||
abstract_done = True
|
||||
p.ai_status = 'done'
|
||||
|
||||
log.info(
|
||||
'Gemini abstract enriched: '
|
||||
'relevance=%d categories=%s title=%s',
|
||||
p.relevance,
|
||||
p.categories,
|
||||
p.title,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
had_failure = True
|
||||
|
||||
log.exception(
|
||||
'Gemini abstract enrichment failed; '
|
||||
'keeping original metadata: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -----------------------------------------
|
||||
# 3. Final AI status
|
||||
# -----------------------------------------
|
||||
if not full_text_done and not abstract_done:
|
||||
if had_failure:
|
||||
p.ai_status = 'failed'
|
||||
|
||||
log.warning(
|
||||
'AI status=failed: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
else:
|
||||
p.ai_status = 'skipped'
|
||||
|
||||
log.info(
|
||||
'AI status=skipped; '
|
||||
'no usable PDF or abstract: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
k = paper_key(p.doi, p.title)
|
||||
|
||||
if k not in new_keys:
|
||||
db.upsert(
|
||||
p,
|
||||
local_date=local_date,
|
||||
)
|
||||
|
||||
# Store every accepted paper, regardless of AI success/failure.
|
||||
for p in papers:
|
||||
if db.upsert(p, local_date=local_date):
|
||||
new.append(p)
|
||||
|
||||
# Daily report is cumulative for the current local date, so reruns safely replace
|
||||
# the same Joplin note without losing papers discovered earlier that day.
|
||||
report_papers = db.list_first_seen_on(local_date)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
jcfg = cfg.get('joplin', {})
|
||||
suffix = period(now, jcfg.get('note_mode', 'daily'))
|
||||
title = f'{jcfg.get("note_title_prefix", "Radar Literature")} - {suffix}'
|
||||
body = render_markdown(report_papers, title, now)
|
||||
out_dir = resolve_path(cfg, app.get('output_dir', './data/outbox'))
|
||||
filename = title.replace('/', '-') + '.md'
|
||||
out = write_markdown(out_dir, filename, body)
|
||||
|
||||
note_id = ''
|
||||
if jcfg.get('enabled') and not dry_run:
|
||||
token = env_value(jcfg.get('token_env'))
|
||||
if not token:
|
||||
log.warning('Joplin token missing; Markdown only')
|
||||
else:
|
||||
try:
|
||||
jc = JoplinClient(jcfg.get('base_url', 'http://127.0.0.1:41184'), token)
|
||||
jc.ping()
|
||||
folder = jc.ensure_folder_path(jcfg.get('notebook_path', ['Research', 'Radar Papers']))
|
||||
note_id = jc.create_or_update_note(title, body, folder, bool(jcfg.get('update_existing_note', True)))
|
||||
for tag in jcfg.get('tags', []):
|
||||
jc.add_tag_to_note(jc.ensure_tag(tag), note_id)
|
||||
except Exception:
|
||||
log.exception('Joplin failed; Markdown kept at %s', out)
|
||||
|
||||
result = {
|
||||
'collected': len(collected),
|
||||
'merged': len(merged),
|
||||
'accepted': len(papers),
|
||||
'new': len(new),
|
||||
'report_count': len(report_papers),
|
||||
'report_title': title,
|
||||
'markdown': out,
|
||||
'joplin_note_id': note_id,
|
||||
}
|
||||
result_path = Path(resolve_path(cfg, './data/last_result.json'))
|
||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
log.info('Result: %s', result)
|
||||
return result
|
||||
|
||||
|
||||
def cli():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--config', default='config.yaml')
|
||||
p.add_argument('--dry-run', action='store_true')
|
||||
a = p.parse_args()
|
||||
run(a.config, a.dry_run)
|
||||
@@ -0,0 +1,6 @@
|
||||
def classify_and_score(paper, search_cfg):
|
||||
hay=' '.join([paper.title,paper.abstract,paper.venue]).lower()
|
||||
paper.categories=[cat for cat,terms in search_cfg.get('categories',{}).items() if any(str(t).lower() in hay for t in terms)] or ['Other']
|
||||
raw=sum(int(w) for term,w in search_cfg.get('relevance_terms',{}).items() if str(term).lower() in hay)
|
||||
paper.relevance=5 if raw>=8 else 4 if raw>=5 else 3 if raw>=3 else 2 if raw>=1 else 1
|
||||
return paper
|
||||
@@ -0,0 +1,119 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from paper_monitor.ai_enrich import enrich_with_gemini_full_text
|
||||
from paper_monitor.db import PaperDB
|
||||
from paper_monitor.models import Paper
|
||||
from paper_monitor.oa_resolver import UnpaywallResolver
|
||||
from paper_monitor.pdf_utils import download_and_extract_pdf
|
||||
from paper_monitor.render import render_markdown
|
||||
|
||||
|
||||
def main():
|
||||
doi = "10.1371/journal.pone.0022647"
|
||||
|
||||
paper = Paper(
|
||||
title="E2E OA AI Test",
|
||||
doi=doi,
|
||||
source="test",
|
||||
source_id="e2e-test",
|
||||
)
|
||||
|
||||
print("1. Resolving OA PDF...")
|
||||
|
||||
resolver = UnpaywallResolver(
|
||||
os.environ["UNPAYWALL_EMAIL"]
|
||||
)
|
||||
resolver.resolve(paper)
|
||||
|
||||
print("oa_status =", paper.oa_status)
|
||||
print("pdf_url =", paper.pdf_url)
|
||||
|
||||
if not paper.pdf_url:
|
||||
raise RuntimeError("OA PDF URL not found")
|
||||
|
||||
print("2. Downloading and extracting PDF...")
|
||||
|
||||
full_text = download_and_extract_pdf(
|
||||
paper.pdf_url
|
||||
)
|
||||
|
||||
print("text_length =", len(full_text))
|
||||
|
||||
print("3. Running Gemini full-text AI...")
|
||||
|
||||
enrich_with_gemini_full_text(
|
||||
paper,
|
||||
full_text,
|
||||
os.environ["GEMINI_API_KEY"],
|
||||
"gemini-3.6-flash",
|
||||
)
|
||||
|
||||
print("analysis_level =", paper.ai_analysis_level)
|
||||
print("relevance =", paper.relevance)
|
||||
print("categories =", paper.categories)
|
||||
print("summary =", paper.summary)
|
||||
print("reason =", paper.ai_reason)
|
||||
|
||||
print("4. Saving to temporary DB...")
|
||||
|
||||
db_path = "/tmp/paper_monitor_e2e.db"
|
||||
|
||||
db = PaperDB(db_path)
|
||||
|
||||
try:
|
||||
db.upsert(
|
||||
paper,
|
||||
local_date="2026-08-15",
|
||||
)
|
||||
|
||||
stored = db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
oa_status,
|
||||
pdf_url,
|
||||
ai_analysis_level,
|
||||
relevance,
|
||||
summary,
|
||||
ai_reason
|
||||
FROM papers
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
print(
|
||||
"db_analysis_level =",
|
||||
stored["ai_analysis_level"],
|
||||
)
|
||||
print(
|
||||
"db_oa_status =",
|
||||
stored["oa_status"],
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print("5. Rendering Markdown...")
|
||||
|
||||
body = render_markdown(
|
||||
[paper],
|
||||
"E2E AI Test",
|
||||
datetime.now(),
|
||||
)
|
||||
|
||||
output = Path("/tmp/e2e_ai_test.md")
|
||||
output.write_text(
|
||||
body,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print("markdown =", output)
|
||||
print("")
|
||||
print(body)
|
||||
|
||||
print("E2E_TEST_OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,168 @@
|
||||
import os
|
||||
|
||||
from paper_monitor.ai_enrich import enrich_with_gemini_full_text
|
||||
from paper_monitor.db import PaperDB
|
||||
from paper_monitor.models import Paper
|
||||
from paper_monitor.oa_resolver import UnpaywallResolver
|
||||
from paper_monitor.pdf_utils import download_and_extract_pdf
|
||||
|
||||
|
||||
def main():
|
||||
db_path = "/tmp/paper_monitor_retry_test.db"
|
||||
|
||||
paper = Paper(
|
||||
title="AI Retry Full Text Test",
|
||||
doi="10.1371/journal.pone.0022647",
|
||||
source="test",
|
||||
source_id="retry-test",
|
||||
ai_status="failed",
|
||||
)
|
||||
|
||||
db = PaperDB(db_path)
|
||||
|
||||
try:
|
||||
print("1. Creating failed paper...")
|
||||
|
||||
db.upsert(
|
||||
paper,
|
||||
local_date="2026-08-15",
|
||||
)
|
||||
|
||||
failed = db.list_ai_failed(limit=10)
|
||||
|
||||
print("failed_count =", len(failed))
|
||||
|
||||
if not failed:
|
||||
raise RuntimeError(
|
||||
"Failed paper was not returned by list_ai_failed()"
|
||||
)
|
||||
|
||||
paper = failed[0]
|
||||
|
||||
print("before_status =", paper.ai_status)
|
||||
|
||||
print("2. Resolving OA PDF...")
|
||||
|
||||
resolver = UnpaywallResolver(
|
||||
os.environ["UNPAYWALL_EMAIL"]
|
||||
)
|
||||
|
||||
resolver.resolve(paper)
|
||||
|
||||
print("oa_status =", paper.oa_status)
|
||||
print("pdf_url =", paper.pdf_url)
|
||||
|
||||
if not paper.pdf_url:
|
||||
raise RuntimeError(
|
||||
"OA PDF URL not found"
|
||||
)
|
||||
|
||||
print("3. Downloading and extracting PDF...")
|
||||
|
||||
full_text = download_and_extract_pdf(
|
||||
paper.pdf_url
|
||||
)
|
||||
|
||||
print("text_length =", len(full_text))
|
||||
|
||||
print("4. Retrying Gemini...")
|
||||
|
||||
enrich_with_gemini_full_text(
|
||||
paper,
|
||||
full_text,
|
||||
os.environ["GEMINI_API_KEY"],
|
||||
"gemini-3.6-flash",
|
||||
)
|
||||
|
||||
paper.ai_status = "done"
|
||||
|
||||
print(
|
||||
"analysis_level =",
|
||||
paper.ai_analysis_level,
|
||||
)
|
||||
print(
|
||||
"after_ai_status =",
|
||||
paper.ai_status,
|
||||
)
|
||||
print(
|
||||
"relevance =",
|
||||
paper.relevance,
|
||||
)
|
||||
print(
|
||||
"categories =",
|
||||
paper.categories,
|
||||
)
|
||||
|
||||
print("5. Updating existing DB row...")
|
||||
|
||||
db.upsert(
|
||||
paper,
|
||||
local_date="2026-08-15",
|
||||
)
|
||||
|
||||
row = db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
ai_status,
|
||||
ai_analysis_level,
|
||||
relevance,
|
||||
summary,
|
||||
ai_reason,
|
||||
oa_status,
|
||||
pdf_url
|
||||
FROM papers
|
||||
WHERE title=?
|
||||
""",
|
||||
(paper.title,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise RuntimeError(
|
||||
"Updated paper not found in DB"
|
||||
)
|
||||
|
||||
print(
|
||||
"db_ai_status =",
|
||||
row["ai_status"],
|
||||
)
|
||||
print(
|
||||
"db_analysis_level =",
|
||||
row["ai_analysis_level"],
|
||||
)
|
||||
print(
|
||||
"db_relevance =",
|
||||
row["relevance"],
|
||||
)
|
||||
|
||||
if row["ai_status"] != "done":
|
||||
raise RuntimeError(
|
||||
"AI retry status was not saved as done"
|
||||
)
|
||||
|
||||
if row["ai_analysis_level"] != "full_text":
|
||||
raise RuntimeError(
|
||||
"AI analysis level was not saved as full_text"
|
||||
)
|
||||
|
||||
failed_after = db.list_ai_failed(
|
||||
limit=10
|
||||
)
|
||||
|
||||
print(
|
||||
"failed_count_after =",
|
||||
len(failed_after),
|
||||
)
|
||||
|
||||
if failed_after:
|
||||
raise RuntimeError(
|
||||
"Paper is still returned as failed after retry"
|
||||
)
|
||||
|
||||
print("RETRY_TEST_OK")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,47 @@
|
||||
import hashlib, html, re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from dateutil import parser as dtparser
|
||||
|
||||
DOI_RE = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I)
|
||||
|
||||
def clean_text(text):
|
||||
if not text: return ""
|
||||
text = html.unescape(str(text))
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
def normalize_doi(doi):
|
||||
if not doi: return ""
|
||||
doi = re.sub(r"^https?://(?:dx\.)?doi\.org/", "", str(doi).strip(), flags=re.I)
|
||||
doi = re.sub(r"^doi:\s*", "", doi, flags=re.I)
|
||||
m = DOI_RE.search(doi)
|
||||
return (m.group(0) if m else doi).rstrip(".,;").lower()
|
||||
|
||||
def normalize_title(title):
|
||||
return re.sub(r"[^a-z0-9가-힣]+", "", clean_text(title).lower())
|
||||
|
||||
def paper_key(doi, title):
|
||||
d = normalize_doi(doi)
|
||||
return "doi:" + d if d else "title:" + hashlib.sha256(normalize_title(title).encode()).hexdigest()
|
||||
|
||||
def parse_date(value):
|
||||
if not value: return ""
|
||||
if isinstance(value, dict):
|
||||
parts = value.get("date-parts", [[]])[0]
|
||||
if parts:
|
||||
y, m, d = parts[0], parts[1] if len(parts)>1 else 1, parts[2] if len(parts)>2 else 1
|
||||
return f"{y:04d}-{m:02d}-{d:02d}"
|
||||
try: return dtparser.parse(str(value)).date().isoformat()
|
||||
except Exception: return ""
|
||||
|
||||
def within_lookback(date_str, days, now=None):
|
||||
if not date_str: return True
|
||||
now = now or datetime.now(timezone.utc)
|
||||
try: return dtparser.parse(date_str).date() >= now.date() - timedelta(days=days)
|
||||
except Exception: return True
|
||||
|
||||
def md_escape(text):
|
||||
return clean_text(text).replace("|", r"\|")
|
||||
|
||||
def safe_url(url):
|
||||
return (url or "").replace(" ", "%20").strip()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semantic_scholar": {
|
||||
"open_until": 1786804590.1067948,
|
||||
"reason": "http_403",
|
||||
"strikes": {
|
||||
"http_403": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime,timezone
|
||||
from paper_monitor.models import Paper
|
||||
from paper_monitor.util import normalize_doi,paper_key
|
||||
from paper_monitor.scoring import classify_and_score
|
||||
from paper_monitor.render import render_markdown
|
||||
|
||||
def test_doi_normalization(): assert normalize_doi('https://doi.org/10.1109/ABC.123.')=='10.1109/abc.123'
|
||||
def test_key_prefers_doi(): assert paper_key('10.1109/X.1','A title')=='doi:10.1109/x.1'
|
||||
def test_scoring():
|
||||
p=Paper(title='Automotive Imaging Radar MIMO Antenna'); cfg={'categories':{'Antenna':['antenna'],'MIMO':['mimo']},'relevance_terms':{'automotive radar':3,'MIMO':2,'antenna':1}}
|
||||
p=classify_and_score(p,cfg); assert 'Antenna' in p.categories; assert p.relevance>=3
|
||||
def test_render():
|
||||
md=render_markdown([Paper(title='Test | Paper',relevance=4,categories=['DOA'],url='https://example.com')],'Report',datetime.now(timezone.utc)); assert 'Test \\| Paper' in md; assert '★★★★' in md
|
||||
Reference in New Issue
Block a user