Compare commits
6
Commits
a8202161e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aa724e90f | ||
|
|
f5186e57b5 | ||
|
|
f39a7b5a09 | ||
|
|
e6d6713169 | ||
|
|
b2db177554 | ||
|
|
f7e31aef1c |
+1
-1
@@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Joplin Terminal (official npm package)
|
||||
RUN npm install --loglevel=error -g joplin
|
||||
RUN npm install --loglevel=error -g joplin@3.7.1
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml requirements.txt ./
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ sync-only 테스트가 정상이라면 `.env`를 수정합니다.
|
||||
JOPLIN_WRITE_ENABLED=true
|
||||
```
|
||||
|
||||
현재 IEEE key가 아직 Waiting이라면 `config.nas.yaml`에서:
|
||||
IEEE developer application이 Waiting 또는 접근이 차단된 경우에만 `config.nas.yaml`에서 비활성화합니다:
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
|
||||
+7
-1
@@ -41,7 +41,7 @@ search:
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false # Set true after the IEEE developer application becomes Active.
|
||||
enabled: true
|
||||
api_key_env: IEEE_API_KEY
|
||||
base_url: https://ieeexploreapi.ieee.org/api/v1/search/articles
|
||||
|
||||
@@ -71,6 +71,12 @@ ai:
|
||||
model: gemini-3.6-flash
|
||||
max_papers_per_run: 20
|
||||
|
||||
fallback:
|
||||
enabled: true
|
||||
provider: groq
|
||||
api_key_env: GROQ_API_KEY
|
||||
model: llama-3.3-70b-versatile
|
||||
|
||||
# NAS mode writes to Joplin through Joplin Terminal, not the desktop Data API.
|
||||
joplin:
|
||||
enabled: false
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ search:
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false
|
||||
enabled: true
|
||||
api_key_env: IEEE_API_KEY
|
||||
base_url: https://ieeexploreapi.ieee.org/api/v1/search/articles
|
||||
semantic_scholar:
|
||||
|
||||
+27
-2
@@ -7,6 +7,31 @@ 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_sync() {
|
||||
local tmp
|
||||
local status
|
||||
|
||||
tmp="$(mktemp)"
|
||||
|
||||
set +e
|
||||
joplin --profile "$PROFILE" sync 2>&1 | tee "$tmp"
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [[ "$status" -ne 0 ]]; then
|
||||
rm -f "$tmp"
|
||||
fail "Joplin sync failed with exit code $status"
|
||||
fi
|
||||
|
||||
if grep -Eqi 'Last error:|(^|[[:space:]])Error:' "$tmp"; then
|
||||
log "ERROR: Joplin reported a sync error:"
|
||||
grep -Ei 'Last error:|(^|[[:space:]])Error:' "$tmp" || true
|
||||
rm -f "$tmp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
: "${JOPLIN_WEBDAV_URL:?JOPLIN_WEBDAV_URL is required}"
|
||||
: "${JOPLIN_WEBDAV_USERNAME:?JOPLIN_WEBDAV_USERNAME is required}"
|
||||
@@ -33,7 +58,7 @@ 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
|
||||
joplin_sync
|
||||
|
||||
if [[ "${JOPLIN_WRITE_ENABLED,,}" != "true" ]]; then
|
||||
log "JOPLIN_WRITE_ENABLED=false: sync-only safety mode. No paper note will be written."
|
||||
@@ -86,6 +111,6 @@ 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
|
||||
joplin_sync
|
||||
|
||||
log "Completed: $REPORT_TITLE ($REPORT_COUNT papers)"
|
||||
|
||||
+41
-49
@@ -1,6 +1,6 @@
|
||||
# Paper Monitor 프로젝트 - 남은 작업 및 검토 TODO
|
||||
|
||||
최종 업데이트: 2026-08-21 (IEEE 접근 차단 해제 확인)
|
||||
최종 업데이트: 2026-08-24 (IEEE API 활성화 및 전체 NAS 실행 완료 반영)
|
||||
|
||||
이 문서는 지금까지 구축한 Synology NAS 기반 논문 자동 모니터링 시스템에서 **아직 미정이거나 수행하지 못한 항목**, 그리고 **운영 전에 변경 또는 추가 검토가 필요한 항목**을 정리한 TODO 목록이다.
|
||||
|
||||
@@ -52,18 +52,13 @@
|
||||
- 검색 query 수 축소
|
||||
- `max_retries` 추가 축소
|
||||
|
||||
### [x] IEEE 접근 차단 해제 여부 확인
|
||||
- 2026-08-21 확인: 집 인터넷에서 `developer.ieee.org` HTTP 200 OK - 차단 해제 완료
|
||||
- API 승인 상태 및 key 교체 여부는 아래 별도 항목에서 확인 필요
|
||||
- 재활성화 시 먼저 1개 query만 수동 테스트 권장
|
||||
- 401 / 403 / 429 / timeout / connection error 발생 시 해당 run의 IEEE 전체 중단 유지
|
||||
- 동일 run에서 IEEE 자동 retry는 하지 않음
|
||||
- 요청 간 최소 간격 5초 유지
|
||||
|
||||
### [ ] IEEE API 승인 상태 확인
|
||||
- 네트워크 차단 문제와 API 승인 문제는 별개
|
||||
- IEEE API key가 최종 승인되었는지 확인 후 활성화
|
||||
- 웹사이트 접속 정상화와 API 승인 상태를 각각 확인
|
||||
### [x] IEEE API 승인·활성화 및 전체 실행 확인 (2026-08-24)
|
||||
- IEEE developer application Active 및 `IEEE_API_KEY` 인증 확인.
|
||||
- `config.yaml`, `config.nas.yaml`에서 `sources.ieee.enabled: true` 적용.
|
||||
- 단일 query smoke test: IEEE Xplore API HTTP 200, JSON 응답 확인.
|
||||
- NAS 운영 구성 전체 실행: 8개 IEEE query에서 15편 수집, 인증·rate-limit 오류 없음.
|
||||
- 요청 간 최소 5초 간격을 유지한다. 주 1회 8 calls/run 운영은 IEEE 한도 10 calls/s, 200 calls/day보다 충분히 낮다.
|
||||
- 403/429/네트워크 오류는 circuit breaker cooldown으로 후속 호출을 억제한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -229,31 +224,26 @@ Direct use of automatic function calling (AFC) in Models.generate_content is not
|
||||
|
||||
## 5. Groq fallback
|
||||
|
||||
### [ ] Groq API 인증 문제 해결
|
||||
현재 상태:
|
||||
- Groq API key를 등록했으나 401 `Invalid API Key`
|
||||
- 새 key 교체 후에도 인증 문제로 일단 보류
|
||||
### [x] Groq API 인증 문제 해결
|
||||
완료 상태:
|
||||
- Groq API key 인증 성공 확인
|
||||
- `models.list()` 및 실제 호출 기반 인증 검증 완료
|
||||
- Gemini 실패 시 Groq fallback 연결 및 동작 확인
|
||||
|
||||
향후:
|
||||
- Groq Console에서 project/key 상태 재확인
|
||||
- `models.list()` 인증 테스트
|
||||
- 인증 성공 후 Gemini fallback provider로 연결
|
||||
|
||||
목표:
|
||||
적용 동작:
|
||||
```text
|
||||
Gemini 성공
|
||||
-> 완료
|
||||
-> 완료
|
||||
|
||||
Gemini quota/timeout/API 오류
|
||||
-> Groq fallback
|
||||
-> Groq fallback
|
||||
|
||||
Groq도 실패
|
||||
-> AI 없이 논문 저장
|
||||
-> AI 없이 논문 저장
|
||||
```
|
||||
|
||||
### [ ] Groq dependency는 인증 성공 후 정식 추가
|
||||
현재 Gemini는 정식 dependency 추가 완료.
|
||||
Groq는 인증 정상화 후 `pyproject.toml`에 추가.
|
||||
### [x] Groq dependency 정식 추가
|
||||
Groq 인증 정상화 이후 dependency 반영 및 실행 검증 완료.
|
||||
|
||||
---
|
||||
|
||||
@@ -277,15 +267,9 @@ Semantic Scholar가 다시 활성화된 뒤 실제 논문에서 `pdf_url`이 정
|
||||
- 404는 `not_found`로 처리, 파이프라인 중단 없이 계속 진행
|
||||
- traceback 없이 조용히 처리
|
||||
|
||||
### [ ] Unpaywall 실제 신규 논문 경로 검증
|
||||
standalone 테스트는 성공했지만 실제 신규 논문 run에서 다음 로그를 아직 충분히 확인하지 못함:
|
||||
|
||||
```text
|
||||
Unpaywall candidates: N / M new papers
|
||||
Unpaywall resolved: status=... pdf=YES ...
|
||||
```
|
||||
|
||||
신규 논문 발생 시 확인.
|
||||
### [x] Unpaywall 실제 신규 논문 경로 검증 (2026-08-24)
|
||||
- 전체 NAS 실행에서 신규 논문 14편 중 DOI가 있는 10편을 Unpaywall로 조회.
|
||||
- 10편 모두 `status=closed`, PDF URL 없음으로 정상 처리됐고 파이프라인은 계속 진행됐다.
|
||||
|
||||
### [ ] PDF 다운로드 실패 유형 통계/로그 개선
|
||||
현재 예외처리는 되어 있음.
|
||||
@@ -469,8 +453,6 @@ OA PDF
|
||||
### [ ] Semantic Scholar 연결/접근 안정화 후 재활성화
|
||||
(기존 섹션 1 참조)
|
||||
|
||||
### [x] IEEE 웹사이트/API 접근 확인 후 재활성화
|
||||
- 2026-08-21: 네트워크 차단 해제 확인. API 승인 상태 및 key 교체 후 `IEEE enabled: true`로 전환 가능
|
||||
|
||||
### [ ] provider circuit breaker 지속 운용
|
||||
현재 circuit breaker 정상 동작 중. 계속 유지.
|
||||
@@ -478,7 +460,7 @@ OA PDF
|
||||
### [ ] Crossref 간헐적 네트워크 타임아웃 모니터링
|
||||
간헐적 타임아웃 발생 여부 실행 로그에서 주기적 확인.
|
||||
|
||||
### [ ] IEEE 복구 후 source 우선순위 재정의
|
||||
### [ ] IEEE 활성화 후 source 우선순위 재정의
|
||||
장기 목표 예:
|
||||
```text
|
||||
IEEE Xplore
|
||||
@@ -559,9 +541,9 @@ pyproject.toml 변경
|
||||
|
||||
---
|
||||
|
||||
# 현재 운영 권장 설정
|
||||
# 현재 운영 설정
|
||||
|
||||
현재 접근 제한 상황을 고려하면 당분간 다음 구성이 안전하다.
|
||||
IEEE API가 활성화되어 IEEE, Crossref, Google Scholar Alert, Gemini, Unpaywall을 사용하는 현재 NAS 구성을 반영한다.
|
||||
|
||||
```yaml
|
||||
app:
|
||||
@@ -572,7 +554,7 @@ app:
|
||||
|
||||
sources:
|
||||
ieee:
|
||||
enabled: false # 네트워크 차단 해제됨. API 승인 및 key 교체 확인 후 true로 전환
|
||||
enabled: true
|
||||
|
||||
semantic_scholar:
|
||||
enabled: false
|
||||
@@ -597,7 +579,7 @@ oa:
|
||||
lookback: 14일
|
||||
```
|
||||
|
||||
IEEE 네트워크 차단은 해제되었으나 API 승인 상태 및 key 교체 확인 전까지 `enabled: false` 유지. 현재 Semantic Scholar도 비활성화 상태이므로 당분간 Crossref 중심으로 수집되고, DOI가 있는 신규 논문에 대해 Unpaywall OA 검색 및 Gemini 분석이 수행된다.
|
||||
IEEE API는 Active 상태이며 `enabled: true`로 운영한다. 주 1회 실행에서 IEEE 8개 query를 요청하고, 요청 간 최소 5초 간격을 유지한다. Semantic Scholar는 계속 비활성화 상태이며, Crossref·Google Scholar Alert 수집 결과에 대해 Unpaywall OA 검색 및 Gemini 분석이 수행된다.
|
||||
|
||||
---
|
||||
|
||||
@@ -616,12 +598,11 @@ IEEE 네트워크 차단은 해제되었으나 API 승인 상태 및 key 교체
|
||||
## P1 - AI
|
||||
- [ ] Gemini 429 RetryInfo/backoff 명시적 처리 (13초 간격이 주 보호, 방어 처리 추가)
|
||||
- [ ] Gemini AFC 경고 정리 (현재 동작에 문제 없음, 낮은 우선순위)
|
||||
- [ ] Gemini -> Groq fallback (인증 문제 미해결, 파이프라인 차단하지 않음)
|
||||
- [x] Gemini -> Groq fallback 구현/검증 완료 (Gemini 실패 시 Groq로 자동 전환)
|
||||
|
||||
## P1 - 소스
|
||||
- [ ] Semantic Scholar 연결/접근 안정화 후 재활성화
|
||||
- [x] IEEE 네트워크 차단 해제 확인 (2026-08-21)
|
||||
- [ ] IEEE API 승인 상태 확인 및 활성화
|
||||
- [x] IEEE API 승인 완료·활성화·전체 NAS 실행 확인 (2026-08-24, 8 queries / 15 papers)
|
||||
- [ ] provider circuit breaker 지속 운용
|
||||
- [ ] Crossref 간헐적 네트워크 타임아웃 모니터링
|
||||
|
||||
@@ -645,7 +626,7 @@ IEEE 네트워크 차단은 해제되었으나 API 승인 상태 및 key 교체
|
||||
|
||||
---
|
||||
|
||||
# 완료 항목 (2026-08-15 기준)
|
||||
# 완료 항목
|
||||
|
||||
## 스케줄러 / 운영
|
||||
- [x] DSM Task Scheduler 최종 등록 확인
|
||||
@@ -672,6 +653,17 @@ scheduler-YYYY-MM-DD_HH-MM-SS.log
|
||||
- [x] Gemini 무료 5 RPM 한도에서 429 오류 없음 확인
|
||||
- [x] 이전 실패(`ai_status=failed`) Gemini 논문 재시도 구현
|
||||
|
||||
## Groq fallback (2026-08-22)
|
||||
- [x] Groq API key 인증 성공 확인
|
||||
- [x] Groq dependency 정식 반영
|
||||
- [x] Gemini 실패 시 Groq fallback 동작 검증 완료
|
||||
|
||||
## IEEE API 활성화 (2026-08-24)
|
||||
- [x] IEEE developer application Active 및 API key 인증 확인
|
||||
- [x] `config.yaml`, `config.nas.yaml`에서 IEEE source 활성화
|
||||
- [x] IEEE Xplore smoke test HTTP 200 JSON 응답 확인
|
||||
- [x] NAS 전체 실행에서 8개 query, 15편 수집 및 rate-limit 오류 없음 확인
|
||||
|
||||
## IEEE 접근 (2026-08-21)
|
||||
- [x] IEEE 네트워크 차단 해제 확인 (`developer.ieee.org` HTTP 200 OK)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"openai>=1.0",
|
||||
"tzdata",
|
||||
"google-genai",
|
||||
"groq",
|
||||
"pypdf",
|
||||
]
|
||||
|
||||
|
||||
+263
-26
@@ -1,6 +1,11 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Gemini rate limiter
|
||||
# ============================================================
|
||||
|
||||
_GEMINI_MIN_INTERVAL = 13.0
|
||||
_GEMINI_LAST_CALL = 0.0
|
||||
|
||||
@@ -17,10 +22,31 @@ def _wait_for_gemini_rate_limit():
|
||||
|
||||
_GEMINI_LAST_CALL = time.monotonic()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Common AI prompt
|
||||
# ============================================================
|
||||
|
||||
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.
|
||||
Analyze ONLY the supplied metadata or full text.
|
||||
Do not invent experimental results, methods, conclusions, or claims that are not supported by the supplied information.
|
||||
|
||||
The primary technical scope is radar engineering, especially:
|
||||
- automotive radar
|
||||
- FMCW radar
|
||||
- radar waveform design
|
||||
- radar MIMO
|
||||
- radar DOA / angle estimation
|
||||
- radar super-resolution
|
||||
- radar interference
|
||||
- automotive radar antenna
|
||||
- imaging radar / 4D radar
|
||||
- radar target modeling
|
||||
- radar signal processing
|
||||
- radar hardware and RF impairments
|
||||
|
||||
Generic communications, acoustic DOA, sonar, medical sensing, or non-radar MIMO/antenna research should receive low relevance unless there is a clear technical connection to radar.
|
||||
|
||||
Return:
|
||||
- relevance: integer from 1 to 5
|
||||
@@ -69,10 +95,14 @@ AI_RESPONSE_SCHEMA = {
|
||||
"categories",
|
||||
"summary",
|
||||
"reason",
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Common helpers
|
||||
# ============================================================
|
||||
|
||||
def _make_payload(paper):
|
||||
return {
|
||||
"title": paper.title,
|
||||
@@ -84,6 +114,18 @@ def _make_payload(paper):
|
||||
}
|
||||
|
||||
|
||||
def _make_full_text_payload(paper, full_text):
|
||||
return {
|
||||
"title": paper.title,
|
||||
"abstract": paper.abstract,
|
||||
"venue": paper.venue,
|
||||
"year": paper.year,
|
||||
"full_text": full_text,
|
||||
"current_categories": paper.categories,
|
||||
"current_relevance": paper.relevance,
|
||||
}
|
||||
|
||||
|
||||
def _apply_result(paper, data):
|
||||
paper.relevance = max(
|
||||
1,
|
||||
@@ -110,6 +152,34 @@ def _apply_result(paper, data):
|
||||
return paper
|
||||
|
||||
|
||||
def _parse_json_text(text):
|
||||
if not text:
|
||||
raise RuntimeError("AI returned empty response")
|
||||
|
||||
text = text.strip()
|
||||
|
||||
# Defensive handling for models that wrap JSON in Markdown fences.
|
||||
if text.startswith("```"):
|
||||
lines = text.splitlines()
|
||||
|
||||
if lines:
|
||||
lines = lines[1:]
|
||||
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Gemini - metadata / abstract
|
||||
# ============================================================
|
||||
|
||||
def enrich_with_gemini(paper, api_key, model):
|
||||
if not api_key:
|
||||
return paper
|
||||
@@ -139,13 +209,18 @@ def enrich_with_gemini(paper, api_key, model):
|
||||
),
|
||||
)
|
||||
|
||||
data = json.loads(response.text)
|
||||
data = _parse_json_text(response.text)
|
||||
|
||||
paper = _apply_result(paper, data)
|
||||
paper.ai_analysis_level = "abstract"
|
||||
|
||||
return paper
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Gemini - full text
|
||||
# ============================================================
|
||||
|
||||
def enrich_with_gemini_full_text(
|
||||
paper,
|
||||
full_text,
|
||||
@@ -163,15 +238,10 @@ def enrich_with_gemini_full_text(
|
||||
|
||||
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,
|
||||
}
|
||||
payload = _make_full_text_payload(
|
||||
paper,
|
||||
full_text,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
SYSTEM
|
||||
@@ -202,14 +272,184 @@ contribution, and relevance to automotive radar when supported by the text.
|
||||
),
|
||||
)
|
||||
|
||||
data = json.loads(response.text)
|
||||
data = _parse_json_text(response.text)
|
||||
|
||||
paper = _apply_result(paper, data)
|
||||
paper.ai_analysis_level = "full_text"
|
||||
|
||||
return paper
|
||||
|
||||
def enrich_with_openai(paper, api_key, model):
|
||||
|
||||
# ============================================================
|
||||
# Groq common prompt
|
||||
# ============================================================
|
||||
|
||||
def _make_groq_prompt(payload, full_text=False):
|
||||
if full_text:
|
||||
instruction = """
|
||||
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.
|
||||
"""
|
||||
else:
|
||||
instruction = """
|
||||
Analyze the supplied paper metadata only.
|
||||
Do not infer details that are not present in the metadata.
|
||||
"""
|
||||
|
||||
output_instruction = """
|
||||
Return ONLY one valid JSON object.
|
||||
|
||||
The JSON object must have exactly these fields:
|
||||
|
||||
{
|
||||
"relevance": 1,
|
||||
"categories": ["Signal Processing"],
|
||||
"summary": "한국어 요약",
|
||||
"reason": "한국어 관련성 설명"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- relevance must be an integer from 1 to 5
|
||||
- categories must be a JSON array of strings
|
||||
- summary must be written in Korean
|
||||
- reason must be written in Korean
|
||||
- do not use Markdown code fences
|
||||
- do not include text before or after the JSON object
|
||||
"""
|
||||
|
||||
return (
|
||||
SYSTEM
|
||||
+ instruction
|
||||
+ output_instruction
|
||||
+ "\n\nPaper data:\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Groq - metadata / abstract
|
||||
# ============================================================
|
||||
|
||||
def enrich_with_groq(
|
||||
paper,
|
||||
api_key,
|
||||
model,
|
||||
):
|
||||
if not api_key:
|
||||
return paper
|
||||
|
||||
from groq import Groq
|
||||
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
payload = _make_payload(paper)
|
||||
|
||||
prompt = _make_groq_prompt(
|
||||
payload,
|
||||
full_text=False,
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
temperature=0.1,
|
||||
response_format={
|
||||
"type": "json_object"
|
||||
},
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
data = _parse_json_text(content)
|
||||
|
||||
paper = _apply_result(paper, data)
|
||||
paper.ai_analysis_level = "abstract"
|
||||
|
||||
return paper
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Groq - full text
|
||||
# ============================================================
|
||||
|
||||
def enrich_with_groq_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 groq import Groq
|
||||
|
||||
client = Groq(api_key=api_key)
|
||||
|
||||
payload = _make_full_text_payload(
|
||||
paper,
|
||||
full_text,
|
||||
)
|
||||
|
||||
prompt = _make_groq_prompt(
|
||||
payload,
|
||||
full_text=True,
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": SYSTEM,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
temperature=0.1,
|
||||
response_format={
|
||||
"type": "json_object"
|
||||
},
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
data = _parse_json_text(content)
|
||||
|
||||
paper = _apply_result(paper, data)
|
||||
paper.ai_analysis_level = "full_text"
|
||||
|
||||
return paper
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OpenAI - legacy / optional
|
||||
# ============================================================
|
||||
|
||||
def enrich_with_openai(
|
||||
paper,
|
||||
api_key,
|
||||
model,
|
||||
):
|
||||
if not api_key:
|
||||
return paper
|
||||
|
||||
@@ -224,18 +464,15 @@ def enrich_with_openai(paper, api_key, model):
|
||||
instructions=SYSTEM,
|
||||
input=json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
text = response.output_text.strip()
|
||||
data = _parse_json_text(
|
||||
response.output_text
|
||||
)
|
||||
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].strip()
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
return _apply_result(paper, data)
|
||||
return _apply_result(
|
||||
paper,
|
||||
data,
|
||||
)
|
||||
+189
-50
@@ -18,6 +18,8 @@ from .joplin import JoplinClient
|
||||
from .ai_enrich import (
|
||||
enrich_with_gemini,
|
||||
enrich_with_gemini_full_text,
|
||||
enrich_with_groq,
|
||||
enrich_with_groq_full_text,
|
||||
)
|
||||
from .collectors.ieee import (
|
||||
IEEECollector,
|
||||
@@ -351,23 +353,57 @@ def run(config_path, dry_run=False):
|
||||
# A. Papers discovered for the first time in this run
|
||||
# B. Papers from earlier runs whose ai_status == "failed"
|
||||
#
|
||||
# Primary provider:
|
||||
# Gemini
|
||||
#
|
||||
# Fallback provider:
|
||||
# Groq
|
||||
#
|
||||
# Full-text flow:
|
||||
# Gemini full-text
|
||||
# -> Groq full-text
|
||||
# -> Gemini abstract
|
||||
# -> Groq abstract
|
||||
#
|
||||
# Abstract-only flow:
|
||||
# Gemini abstract
|
||||
# -> Groq abstract
|
||||
#
|
||||
# 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(
|
||||
gemini_api_key = env_value(
|
||||
ai.get('api_key_env', 'GEMINI_API_KEY')
|
||||
)
|
||||
|
||||
model = ai.get(
|
||||
gemini_model = ai.get(
|
||||
'model',
|
||||
'gemini-3.6-flash',
|
||||
)
|
||||
|
||||
fallback = ai.get('fallback', {})
|
||||
groq_enabled = bool(
|
||||
fallback.get('enabled', False)
|
||||
) and fallback.get('provider', 'groq').lower() == 'groq'
|
||||
|
||||
groq_api_key = ''
|
||||
groq_model = fallback.get(
|
||||
'model',
|
||||
'llama-3.3-70b-versatile',
|
||||
)
|
||||
|
||||
if groq_enabled:
|
||||
groq_api_key = env_value(
|
||||
fallback.get(
|
||||
'api_key_env',
|
||||
'GROQ_API_KEY',
|
||||
)
|
||||
)
|
||||
|
||||
max_papers = int(
|
||||
ai.get('max_papers_per_run', 20)
|
||||
)
|
||||
@@ -383,7 +419,9 @@ def run(config_path, dry_run=False):
|
||||
candidate_map = {}
|
||||
|
||||
for p in new_candidates:
|
||||
candidate_map[paper_key(p.doi, p.title)] = p
|
||||
candidate_map[
|
||||
paper_key(p.doi, p.title)
|
||||
] = p
|
||||
|
||||
for p in retry_candidates:
|
||||
k = paper_key(p.doi, p.title)
|
||||
@@ -401,24 +439,28 @@ def run(config_path, dry_run=False):
|
||||
reverse=True,
|
||||
)[:max_papers]
|
||||
|
||||
if not api_key:
|
||||
if not gemini_api_key and not groq_api_key:
|
||||
log.warning(
|
||||
'Gemini API key missing; AI enrichment skipped'
|
||||
'No usable AI API key configured; '
|
||||
'AI enrichment skipped'
|
||||
)
|
||||
|
||||
else:
|
||||
log.info(
|
||||
'Gemini enrichment candidates: %d '
|
||||
'(new=%d retry=%d)',
|
||||
'AI enrichment candidates: %d '
|
||||
'(new=%d retry=%d, gemini=%s groq=%s)',
|
||||
len(ai_candidates),
|
||||
len(new_candidates),
|
||||
len(retry_candidates),
|
||||
'YES' if gemini_api_key else 'NO',
|
||||
'YES' if groq_api_key else 'NO',
|
||||
)
|
||||
|
||||
for p in ai_candidates:
|
||||
full_text_done = False
|
||||
abstract_done = False
|
||||
had_failure = False
|
||||
full_text = ''
|
||||
|
||||
# -----------------------------------------
|
||||
# 1. Prefer OA PDF full-text analysis
|
||||
@@ -440,33 +482,89 @@ def run(config_path, dry_run=False):
|
||||
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; '
|
||||
'PDF download/extraction failed; '
|
||||
'trying abstract fallback: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -------------------------------------
|
||||
# 1-A. Gemini full-text
|
||||
# -------------------------------------
|
||||
if (
|
||||
full_text
|
||||
and full_text.strip()
|
||||
and gemini_api_key
|
||||
):
|
||||
try:
|
||||
enrich_with_gemini_full_text(
|
||||
p,
|
||||
full_text,
|
||||
gemini_api_key,
|
||||
gemini_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(
|
||||
'Gemini full-text enrichment failed; '
|
||||
'trying Groq full-text fallback: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -------------------------------------
|
||||
# 1-B. Groq full-text fallback
|
||||
# -------------------------------------
|
||||
if (
|
||||
not full_text_done
|
||||
and full_text
|
||||
and full_text.strip()
|
||||
and groq_enabled
|
||||
and groq_api_key
|
||||
):
|
||||
try:
|
||||
enrich_with_groq_full_text(
|
||||
p,
|
||||
full_text,
|
||||
groq_api_key,
|
||||
groq_model,
|
||||
)
|
||||
|
||||
full_text_done = True
|
||||
p.ai_status = 'done'
|
||||
|
||||
log.info(
|
||||
'Groq full-text enriched: '
|
||||
'relevance=%d categories=%s title=%s',
|
||||
p.relevance,
|
||||
p.categories,
|
||||
p.title,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
had_failure = True
|
||||
|
||||
log.exception(
|
||||
'Groq full-text enrichment failed; '
|
||||
'trying abstract fallback: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -----------------------------------------
|
||||
# 2. Abstract fallback
|
||||
# -----------------------------------------
|
||||
@@ -475,32 +573,71 @@ def run(config_path, dry_run=False):
|
||||
and p.abstract
|
||||
and p.abstract.strip()
|
||||
):
|
||||
try:
|
||||
enrich_with_gemini(
|
||||
p,
|
||||
api_key,
|
||||
model,
|
||||
)
|
||||
# -------------------------------------
|
||||
# 2-A. Gemini abstract
|
||||
# -------------------------------------
|
||||
if gemini_api_key:
|
||||
try:
|
||||
enrich_with_gemini(
|
||||
p,
|
||||
gemini_api_key,
|
||||
gemini_model,
|
||||
)
|
||||
|
||||
abstract_done = True
|
||||
p.ai_status = 'done'
|
||||
abstract_done = True
|
||||
p.ai_status = 'done'
|
||||
|
||||
log.info(
|
||||
'Gemini abstract enriched: '
|
||||
'relevance=%d categories=%s title=%s',
|
||||
p.relevance,
|
||||
p.categories,
|
||||
p.title,
|
||||
)
|
||||
log.info(
|
||||
'Gemini abstract enriched: '
|
||||
'relevance=%d categories=%s title=%s',
|
||||
p.relevance,
|
||||
p.categories,
|
||||
p.title,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
had_failure = True
|
||||
except Exception:
|
||||
had_failure = True
|
||||
|
||||
log.exception(
|
||||
'Gemini abstract enrichment failed; '
|
||||
'keeping original metadata: %s',
|
||||
p.title,
|
||||
)
|
||||
log.exception(
|
||||
'Gemini abstract enrichment failed; '
|
||||
'trying Groq abstract fallback: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -------------------------------------
|
||||
# 2-B. Groq abstract fallback
|
||||
# -------------------------------------
|
||||
if (
|
||||
not abstract_done
|
||||
and groq_enabled
|
||||
and groq_api_key
|
||||
):
|
||||
try:
|
||||
enrich_with_groq(
|
||||
p,
|
||||
groq_api_key,
|
||||
groq_model,
|
||||
)
|
||||
|
||||
abstract_done = True
|
||||
p.ai_status = 'done'
|
||||
|
||||
log.info(
|
||||
'Groq abstract enriched: '
|
||||
'relevance=%d categories=%s title=%s',
|
||||
p.relevance,
|
||||
p.categories,
|
||||
p.title,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
had_failure = True
|
||||
|
||||
log.exception(
|
||||
'Groq abstract enrichment failed; '
|
||||
'keeping original metadata: %s',
|
||||
p.title,
|
||||
)
|
||||
|
||||
# -----------------------------------------
|
||||
# 3. Final AI status
|
||||
@@ -525,6 +662,8 @@ def run(config_path, dry_run=False):
|
||||
|
||||
k = paper_key(p.doi, p.title)
|
||||
|
||||
# Retry candidates are existing DB rows, so persist
|
||||
# their updated AI status immediately.
|
||||
if k not in new_keys:
|
||||
db.upsert(
|
||||
p,
|
||||
@@ -589,4 +728,4 @@ def cli():
|
||||
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)
|
||||
run(a.config, a.dry_run)
|
||||
@@ -10,5 +10,12 @@
|
||||
"open_until": 0.0,
|
||||
"reason": null,
|
||||
"strikes": {}
|
||||
},
|
||||
"ieee": {
|
||||
"open_until": 1790933827.9323854,
|
||||
"reason": "http_403",
|
||||
"strikes": {
|
||||
"http_403": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user